dsh-dbhub-live 5.0.1 → 5.2.0-dev.3
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/AGENTS.md +95 -209
- package/README.en.md +251 -169
- package/README.md +251 -169
- package/doc/ARCHITECTURE.md +108 -0
- package/doc/CLIENT.md +124 -0
- package/doc/DESENSITIZE.md +56 -0
- package/doc/DEVELOPMENT.md +139 -0
- package/doc/IMPACT-MATRIX.md +36 -0
- package/doc/RELEASE.md +93 -0
- package/doc/REQUIREMENTS.md +177 -105
- package/doc/images/chat-query.png +0 -0
- package/doc/images/row-editor.png +0 -0
- package/doc/images/settings-page.png +0 -0
- package/lib/adhoc.mjs +412 -34
- package/lib/capability.mjs +157 -0
- package/lib/client.js +1243 -80
- package/lib/config.mjs +370 -21
- package/lib/i18n.mjs +119 -4
- package/lib/index.mjs +401 -43
- package/lib/mcp.mjs +496 -391
- package/lib/meta.mjs +267 -0
- package/lib/options.mjs +25 -5
- package/lib/policy.mjs +92 -0
- package/lib/toml.mjs +188 -0
- package/lib/tools.mjs +1093 -827
- package/lib/update.mjs +244 -0
- package/package.json +34 -10
- package/scripts/desensitize-check.mjs +10 -3
- package/scripts/desensitize.config.mjs +255 -237
- package/scripts/docs-check.mjs +214 -0
- package/scripts/release-preflight.mjs +180 -0
- package/scripts/render-check.mjs +1366 -843
- package/test/adhoc-toml.test.mjs +365 -0
- package/test/bridge.test.mjs +227 -185
- package/test/capability.test.mjs +231 -0
- package/test/client-format.test.mjs +515 -352
- package/test/configure-options.test.mjs +244 -0
- package/test/configure-ssh.test.mjs +232 -0
- package/test/copy-from.test.mjs +155 -0
- package/test/env-options.test.mjs +456 -0
- package/test/meta.test.mjs +234 -0
- package/test/options.test.mjs +160 -123
- package/test/policy.test.mjs +132 -0
- package/test/row-cache.test.mjs +237 -138
- package/test/ssh-probe.test.mjs +834 -0
- package/test/test-op-fastpath.test.mjs +208 -0
- package/test/toml.test.mjs +352 -0
- package/test/update.test.mjs +270 -0
- package/test/util.test.mjs +17 -0
- package/test/zero-knowledge.test.mjs +120 -78
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Unit tests for the Host's version metadata (lib/meta.mjs): semver parsing and
|
|
2
|
+
// precedence, the "is an update newer?" question the update checker asks, the
|
|
3
|
+
// dsh >= 0.2.1 compatibility gate, and the runtime probes (plugin version from
|
|
4
|
+
// package.json, dsh version from an installed package layout).
|
|
5
|
+
//
|
|
6
|
+
// The discovery walk is driven against a temp layout with a fake argv entry, so
|
|
7
|
+
// these tests never touch the real dsh installation; no DSH_HOME / store paths
|
|
8
|
+
// are involved at all (this module reads package.json files only).
|
|
9
|
+
|
|
10
|
+
import test from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
DSH_MODERN_BASE,
|
|
18
|
+
DSH_PACKAGE_NAMES,
|
|
19
|
+
PLUGIN_NAME,
|
|
20
|
+
atLeastBase,
|
|
21
|
+
compareSemver,
|
|
22
|
+
detectDshRuntime,
|
|
23
|
+
dshIsModern,
|
|
24
|
+
isNewer,
|
|
25
|
+
parseSemver,
|
|
26
|
+
readPluginVersion,
|
|
27
|
+
versionMeta,
|
|
28
|
+
walkDshVersion,
|
|
29
|
+
} from '../lib/meta.mjs'
|
|
30
|
+
|
|
31
|
+
// ── semver primitives ─────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
test('parseSemver accepts v-prefix, prerelease and build metadata', () => {
|
|
34
|
+
assert.deepEqual(parseSemver('1.2.3').prerelease, [])
|
|
35
|
+
assert.equal(parseSemver('v5.2.0').major, 5)
|
|
36
|
+
assert.equal(parseSemver('v5.2.0').minor, 2)
|
|
37
|
+
assert.equal(parseSemver(' v5.2.0-dev.1 ').patch, 0)
|
|
38
|
+
assert.deepEqual(parseSemver('5.2.0-dev.1').prerelease, ['dev', '1'])
|
|
39
|
+
// Prerelease AND build metadata together: only the prerelease is compared.
|
|
40
|
+
assert.deepEqual(parseSemver('1.0.0-rc.1+build').prerelease, ['rc', '1'])
|
|
41
|
+
// Build metadata never participates in parsing beyond being accepted.
|
|
42
|
+
assert.deepEqual(parseSemver('1.0.0+sha.abc').prerelease, [])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('parseSemver rejects non-semver junk instead of coercing it', () => {
|
|
46
|
+
for (const bad of ['', ' ', 'latest', '1.2', '192.0.2.10', 'x.y.z', '1..2', '1.2.3-', null, undefined, 42, 'dev.1']) {
|
|
47
|
+
assert.equal(parseSemver(bad), undefined, 'should reject: ' + String(bad))
|
|
48
|
+
}
|
|
49
|
+
// Empty prerelease identifiers are invalid semver.
|
|
50
|
+
assert.equal(parseSemver('1.2.3-alpha..beta'), undefined)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('compareSemver follows semver precedence (incl. prerelease ordering)', () => {
|
|
54
|
+
// The canonical chain from semver.org §11.
|
|
55
|
+
const chain = [
|
|
56
|
+
'1.0.0-alpha',
|
|
57
|
+
'1.0.0-alpha.1',
|
|
58
|
+
'1.0.0-alpha.beta',
|
|
59
|
+
'1.0.0-beta',
|
|
60
|
+
'1.0.0-beta.2',
|
|
61
|
+
'1.0.0-beta.11',
|
|
62
|
+
'1.0.0-rc.1',
|
|
63
|
+
'1.0.0',
|
|
64
|
+
]
|
|
65
|
+
for (let i = 0; i < chain.length - 1; i += 1) {
|
|
66
|
+
assert.equal(compareSemver(chain[i], chain[i + 1]), -1, chain[i] + ' < ' + chain[i + 1])
|
|
67
|
+
assert.equal(compareSemver(chain[i + 1], chain[i]), 1, chain[i + 1] + ' > ' + chain[i])
|
|
68
|
+
assert.equal(isNewer(chain[i + 1], chain[i]), true)
|
|
69
|
+
assert.equal(isNewer(chain[i], chain[i + 1]), false)
|
|
70
|
+
}
|
|
71
|
+
// Numeric identifiers compare numerically (not lexically): 2 < 11.
|
|
72
|
+
assert.equal(compareSemver('1.0.0-beta.2', '1.0.0-beta.11'), -1)
|
|
73
|
+
// Build metadata is ignored for precedence.
|
|
74
|
+
assert.equal(compareSemver('1.0.0+build.9', '1.0.0+sha.1'), 0)
|
|
75
|
+
assert.equal(compareSemver('1.0.0', 'v1.0.0'), 0)
|
|
76
|
+
// Triplet ordering.
|
|
77
|
+
assert.equal(compareSemver('0.2.1', '0.2.0'), 1)
|
|
78
|
+
assert.equal(compareSemver('5.2.0', '5.10.0'), -1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('compareSemver/isNewer never throw on invalid input', () => {
|
|
82
|
+
assert.equal(compareSemver('nope', '1.0.0'), 0)
|
|
83
|
+
assert.equal(compareSemver('1.0.0', undefined), 0)
|
|
84
|
+
assert.equal(isNewer('5.2.0', ''), false, 'unknown current version never claims an update')
|
|
85
|
+
assert.equal(isNewer('', '5.1.0'), false)
|
|
86
|
+
assert.equal(isNewer('garbage', '5.1.0'), false)
|
|
87
|
+
assert.equal(isNewer(undefined, undefined), false)
|
|
88
|
+
// compareSemver also takes already-parsed objects.
|
|
89
|
+
assert.equal(compareSemver(parseSemver('1.0.0'), '1.0.0'), 0)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('isNewer is prerelease-aware for this repo dev builds', () => {
|
|
93
|
+
// A dev build is OLDER than the release with the same base version ...
|
|
94
|
+
assert.equal(isNewer('5.2.0', '5.2.0-dev.1'), true)
|
|
95
|
+
assert.equal(isNewer('5.2.0-dev.2', '5.2.0-dev.1'), true)
|
|
96
|
+
assert.equal(isNewer('5.2.0-dev.1', '5.2.0'), false)
|
|
97
|
+
// ... and 5.2.0-dev.1 is NEWER than the previous 5.1.x release line.
|
|
98
|
+
assert.equal(isNewer('5.2.0-dev.1', '5.1.0'), true)
|
|
99
|
+
// Equal versions are never an update.
|
|
100
|
+
assert.equal(isNewer('5.2.0', '5.2.0'), false)
|
|
101
|
+
assert.equal(isNewer('5.2.0-dev.1', '5.2.0-dev.1'), false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ── the dsh >= 0.2.1 gate ─────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
test('atLeastBase compares the numeric triplet and ignores prerelease', () => {
|
|
107
|
+
assert.equal(atLeastBase('0.2.1-rc.2', DSH_MODERN_BASE), true, 'a 0.2.1 prerelease counts as 0.2.1')
|
|
108
|
+
assert.equal(atLeastBase('0.2.1', DSH_MODERN_BASE), true)
|
|
109
|
+
assert.equal(atLeastBase('0.2.2', DSH_MODERN_BASE), true)
|
|
110
|
+
assert.equal(atLeastBase('1.0.0', DSH_MODERN_BASE), true)
|
|
111
|
+
assert.equal(atLeastBase('0.2.0-rc.2', DSH_MODERN_BASE), false)
|
|
112
|
+
assert.equal(atLeastBase('0.1.9', DSH_MODERN_BASE), false)
|
|
113
|
+
assert.equal(atLeastBase('', DSH_MODERN_BASE), false)
|
|
114
|
+
assert.equal(atLeastBase('unknown', DSH_MODERN_BASE), false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('dshIsModern: resolved version wins; unresolvable is modern only on desktop', () => {
|
|
118
|
+
assert.equal(dshIsModern({ version: '0.2.1-rc.2', desktop: false }), true)
|
|
119
|
+
assert.equal(dshIsModern({ version: '0.2.1-rc.2', desktop: true }), true)
|
|
120
|
+
assert.equal(dshIsModern({ version: '0.1.6-alpha.2', desktop: true }), false, 'an old resolved version is old even on desktop')
|
|
121
|
+
assert.equal(dshIsModern({ version: '', desktop: true }), true, 'desktop with an unresolvable version stays modern')
|
|
122
|
+
assert.equal(dshIsModern({ version: '', desktop: false }), false, 'unknown web version => legacy UI')
|
|
123
|
+
assert.equal(dshIsModern({}), false)
|
|
124
|
+
assert.equal(dshIsModern(undefined), false)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// ── runtime discovery ─────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function writePkg(dir, name, version) {
|
|
130
|
+
mkdirSync(dir, { recursive: true })
|
|
131
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version }, null, 2))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test('walkDshVersion finds the installed dsh package above a fake argv entry', () => {
|
|
135
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-meta-walk-'))
|
|
136
|
+
try {
|
|
137
|
+
// <root>/app is the dsh package; the entry point is <root>/app/lib/cli.js.
|
|
138
|
+
writePkg(join(root, 'app'), '@deepseek-ai/dsh', '0.2.1-rc.2')
|
|
139
|
+
const entry = join(root, 'app', 'lib', 'cli.js')
|
|
140
|
+
mkdirSync(dirname(entry), { recursive: true })
|
|
141
|
+
writeFileSync(entry, '// fake entry\n')
|
|
142
|
+
assert.equal(walkDshVersion(entry), '0.2.1-rc.2')
|
|
143
|
+
|
|
144
|
+
// A directory entry works too (some loaders pass one).
|
|
145
|
+
assert.equal(walkDshVersion(join(root, 'app', 'lib')), '0.2.1-rc.2')
|
|
146
|
+
|
|
147
|
+
// A nearer, non-dsh package.json is skipped rather than accepted.
|
|
148
|
+
writePkg(join(root, 'app', 'lib'), 'some-other-package', '9.9.9')
|
|
149
|
+
assert.equal(walkDshVersion(entry), '0.2.1-rc.2')
|
|
150
|
+
|
|
151
|
+
// An unresolvable path simply yields ''.
|
|
152
|
+
assert.equal(walkDshVersion(join(root, 'nope', 'nested', 'cli.js')), '')
|
|
153
|
+
assert.equal(walkDshVersion(''), '')
|
|
154
|
+
assert.equal(walkDshVersion(undefined), '')
|
|
155
|
+
} finally {
|
|
156
|
+
rmSync(root, { recursive: true, force: true })
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('walkDshVersion honours the maxDepth budget', () => {
|
|
161
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-meta-depth-'))
|
|
162
|
+
try {
|
|
163
|
+
writePkg(root, '@deepseek-ai/dsh', '0.2.1')
|
|
164
|
+
// 10 directories between the entry file and the package root: beyond the
|
|
165
|
+
// default 8-level walk, so the version must NOT be found.
|
|
166
|
+
let dir = root
|
|
167
|
+
const parts = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
|
|
168
|
+
for (const part of parts) dir = join(dir, part)
|
|
169
|
+
mkdirSync(dir, { recursive: true })
|
|
170
|
+
const entry = join(dir, 'cli.js')
|
|
171
|
+
writeFileSync(entry, '// fake entry\n')
|
|
172
|
+
assert.equal(walkDshVersion(entry), '')
|
|
173
|
+
assert.equal(walkDshVersion(entry, { maxDepth: 12 }), '0.2.1')
|
|
174
|
+
// A package.json whose version is not semver is ignored.
|
|
175
|
+
const other = mkdtempSync(join(tmpdir(), 'dsh-meta-badver-'))
|
|
176
|
+
try {
|
|
177
|
+
writePkg(join(other, 'pkg'), '@deepseek-ai/dsh', 'not-a-version')
|
|
178
|
+
assert.equal(walkDshVersion(join(other, 'pkg', 'bin.js')), '')
|
|
179
|
+
} finally {
|
|
180
|
+
rmSync(other, { recursive: true, force: true })
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
rmSync(root, { recursive: true, force: true })
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('walkDshVersion recognises every documented host package name', () => {
|
|
188
|
+
assert.deepEqual([...DSH_PACKAGE_NAMES], [
|
|
189
|
+
'@deepseek-ai/dsh',
|
|
190
|
+
'@deepseek-ai/dsh-desktop-runtime',
|
|
191
|
+
'@deepseek-ai/dsh-desktop',
|
|
192
|
+
])
|
|
193
|
+
for (const name of DSH_PACKAGE_NAMES) {
|
|
194
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-meta-name-'))
|
|
195
|
+
try {
|
|
196
|
+
writePkg(root, name, '0.2.1')
|
|
197
|
+
assert.equal(walkDshVersion(join(root, 'lib', 'bin.js')), '0.2.1', name)
|
|
198
|
+
} finally {
|
|
199
|
+
rmSync(root, { recursive: true, force: true })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('readPluginVersion equals this package.json version and is cached', () => {
|
|
205
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
206
|
+
assert.equal(PLUGIN_NAME, 'dsh-dbhub-live')
|
|
207
|
+
assert.equal(readPluginVersion(), pkg.version)
|
|
208
|
+
assert.equal(readPluginVersion(), pkg.version, 'the value is stable across calls')
|
|
209
|
+
// The package.json version must itself be semver, otherwise the update check
|
|
210
|
+
// would be comparing against an unknown version forever.
|
|
211
|
+
assert.ok(parseSemver(readPluginVersion()), 'package.json version must be semver')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('detectDshRuntime reports a coherent runtime (never throws)', () => {
|
|
215
|
+
const runtime = detectDshRuntime()
|
|
216
|
+
assert.equal(typeof runtime.version, 'string')
|
|
217
|
+
assert.equal(typeof runtime.desktop, 'boolean')
|
|
218
|
+
// This test process runs under plain node (no Electron), so unless the shell
|
|
219
|
+
// exports DSH_CLIENT_VERSION the runtime must be web.
|
|
220
|
+
if (!process.versions.electron && !process.env.DSH_CLIENT_VERSION) {
|
|
221
|
+
assert.equal(runtime.desktop, false)
|
|
222
|
+
assert.equal(runtime.version, '')
|
|
223
|
+
}
|
|
224
|
+
if (runtime.version) assert.ok(parseSemver(runtime.version), 'a resolved version is always semver')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('versionMeta publishes the four view fields', () => {
|
|
228
|
+
const meta = versionMeta()
|
|
229
|
+
assert.deepEqual(Object.keys(meta).sort(), ['dshModern', 'dshVersion', 'pluginVersion', 'runtime'])
|
|
230
|
+
assert.equal(meta.pluginVersion, readPluginVersion())
|
|
231
|
+
assert.ok(meta.runtime === 'web' || meta.runtime === 'desktop')
|
|
232
|
+
assert.equal(meta.dshModern, dshIsModern({ version: meta.dshVersion, desktop: meta.runtime === 'desktop' }))
|
|
233
|
+
assert.equal(typeof meta.dshModern, 'boolean')
|
|
234
|
+
})
|
package/test/options.test.mjs
CHANGED
|
@@ -1,123 +1,160 @@
|
|
|
1
|
-
// Unit tests for the configurable options (lib/options.mjs): env seeding for
|
|
2
|
-
// the internal package knob, the
|
|
3
|
-
// layer, validated patches, and change notifications.
|
|
4
|
-
//
|
|
5
|
-
// The module derives both the storage directory and the persisted prefs layer
|
|
6
|
-
// from DSH_HOME at import time, so DSH_HOME is pointed at a temp dir BEFORE the
|
|
7
|
-
// modules load, and each layer is probed through a fresh module instance.
|
|
8
|
-
|
|
9
|
-
import test from 'node:test'
|
|
10
|
-
import assert from 'node:assert/strict'
|
|
11
|
-
import { mkdtempSync, readFileSync } from 'node:fs'
|
|
12
|
-
import { tmpdir } from 'node:os'
|
|
13
|
-
import { join } from 'node:path'
|
|
14
|
-
|
|
15
|
-
const home = mkdtempSync(join(tmpdir(), 'dbhub-options-'))
|
|
16
|
-
// env seeding happens at import time — set these before importing.
|
|
17
|
-
process.env.DSH_HOME = home
|
|
18
|
-
process.env.DSH_DBHUB_PACKAGE = 'test/fork-package'
|
|
19
|
-
process.env.DSH_DBHUB_UPDATE_DAYS = '3'
|
|
20
|
-
const options = await import('../lib/options.mjs?options2=' + Date.now())
|
|
21
|
-
|
|
22
|
-
const PREFS_PATH = join(home, 'storages', 'dsh-dbhub-live', 'prefs.json')
|
|
23
|
-
|
|
24
|
-
test('options seed from process environment; package stays internal-only', () => {
|
|
25
|
-
assert.equal(options.get('dbhubPackage'), 'test/fork-package') // internal
|
|
26
|
-
assert.equal(options.get('updateIntervalDays'), 3)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
test('snapshot exposes
|
|
30
|
-
const snap = options.snapshot()
|
|
31
|
-
assert.deepEqual(Object.keys(snap).sort(), ['showSidebarEntry', 'updateIntervalDays'])
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
assert.equal(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
assert.equal(options.applyPatch({
|
|
46
|
-
assert.equal(options.get('
|
|
47
|
-
assert.equal(options.
|
|
48
|
-
assert.equal(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
assert.equal(options.
|
|
52
|
-
assert.equal(options.applyPatch({ showSidebarEntry:
|
|
53
|
-
assert.
|
|
54
|
-
|
|
55
|
-
assert.equal(options.applyPatch(
|
|
56
|
-
assert.equal(options.
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
assert.
|
|
63
|
-
|
|
64
|
-
assert.equal(options.applyPatch(
|
|
65
|
-
assert.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
assert.equal(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
assert.equal(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
assert.equal(
|
|
83
|
-
assert.equal(
|
|
84
|
-
assert.equal(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
assert.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
assert.
|
|
110
|
-
assert.
|
|
111
|
-
|
|
112
|
-
assert.
|
|
113
|
-
assert.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
assert.equal(
|
|
120
|
-
assert.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1
|
+
// Unit tests for the configurable options (lib/options.mjs): env seeding for
|
|
2
|
+
// the internal package knob, the one UI-exposed field, the persisted user
|
|
3
|
+
// layer, validated patches, and change notifications.
|
|
4
|
+
//
|
|
5
|
+
// The module derives both the storage directory and the persisted prefs layer
|
|
6
|
+
// from DSH_HOME at import time, so DSH_HOME is pointed at a temp dir BEFORE the
|
|
7
|
+
// modules load, and each layer is probed through a fresh module instance.
|
|
8
|
+
|
|
9
|
+
import test from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
const home = mkdtempSync(join(tmpdir(), 'dbhub-options-'))
|
|
16
|
+
// env seeding happens at import time — set these before importing.
|
|
17
|
+
process.env.DSH_HOME = home
|
|
18
|
+
process.env.DSH_DBHUB_PACKAGE = 'test/fork-package'
|
|
19
|
+
process.env.DSH_DBHUB_UPDATE_DAYS = '3'
|
|
20
|
+
const options = await import('../lib/options.mjs?options2=' + Date.now())
|
|
21
|
+
|
|
22
|
+
const PREFS_PATH = join(home, 'storages', 'dsh-dbhub-live', 'prefs.json')
|
|
23
|
+
|
|
24
|
+
test('options seed from process environment; package stays internal-only', () => {
|
|
25
|
+
assert.equal(options.get('dbhubPackage'), 'test/fork-package') // internal
|
|
26
|
+
assert.equal(options.get('updateIntervalDays'), 3)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('snapshot exposes every UI field (sidebar switch, policy announcement)', () => {
|
|
30
|
+
const snap = options.snapshot()
|
|
31
|
+
assert.deepEqual(Object.keys(snap).sort(), ['announceAccessPolicy', 'showSidebarEntry', 'updateIntervalDays'])
|
|
32
|
+
// The switch hides the SHORTCUT; it does not disable the plugin. The client
|
|
33
|
+
// ANDs it with the enabled state.
|
|
34
|
+
assert.equal(snap.showSidebarEntry, true)
|
|
35
|
+
// The policy announcement defaults ON: an installed plugin should be the
|
|
36
|
+
// route database work takes. Turning it off removes one prompt section only.
|
|
37
|
+
assert.equal(snap.announceAccessPolicy, true)
|
|
38
|
+
snap.updateIntervalDays = 99
|
|
39
|
+
assert.equal(options.get('updateIntervalDays'), 3)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('applyPatch accepts valid values, emits, and rejects invalid', () => {
|
|
43
|
+
const seen = []
|
|
44
|
+
const off = options.subscribe(() => seen.push(options.snapshot()))
|
|
45
|
+
assert.equal(options.applyPatch({ updateIntervalDays: 0, dbhubPackage: 'ignored' }), true)
|
|
46
|
+
assert.equal(options.get('updateIntervalDays'), 0)
|
|
47
|
+
assert.equal(options.get('dbhubPackage'), 'test/fork-package') // not settable via patch
|
|
48
|
+
assert.equal(seen.length, 1)
|
|
49
|
+
// the sidebar switch is a boolean-only option
|
|
50
|
+
assert.equal(options.applyPatch({ showSidebarEntry: false }), true)
|
|
51
|
+
assert.equal(options.get('showSidebarEntry'), false)
|
|
52
|
+
assert.equal(options.applyPatch({ showSidebarEntry: 'yes' }), false)
|
|
53
|
+
assert.equal(options.get('showSidebarEntry'), false)
|
|
54
|
+
// ... and so is the policy announcement (boolean-only, no truthy coercion)
|
|
55
|
+
assert.equal(options.applyPatch({ announceAccessPolicy: false }), true)
|
|
56
|
+
assert.equal(options.get('announceAccessPolicy'), false)
|
|
57
|
+
assert.equal(options.applyPatch({ announceAccessPolicy: 'no' }), false)
|
|
58
|
+
assert.equal(options.get('announceAccessPolicy'), false)
|
|
59
|
+
off()
|
|
60
|
+
const before = options.snapshot()
|
|
61
|
+
assert.equal(options.applyPatch({ updateIntervalDays: -1, enabled: false }), false)
|
|
62
|
+
assert.equal(options.applyPatch({ showSidebarEntry: false }), false) // already false: no emit
|
|
63
|
+
assert.deepEqual(options.snapshot(), before)
|
|
64
|
+
assert.equal(options.applyPatch(null), false)
|
|
65
|
+
assert.equal(options.applyPatch(['a']), false)
|
|
66
|
+
assert.equal(options.applyPatch({ updateIntervalDays: 0 }), false) // no-op does not emit
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('a user write claims the field and persists it for the next boot', async () => {
|
|
70
|
+
assert.equal(options.applyPatch({ updateIntervalDays: 5 }, { persist: true }), true)
|
|
71
|
+
// prefs.json is the whole UI layer, so it carries every UI field.
|
|
72
|
+
assert.deepEqual(JSON.parse(readFileSync(PREFS_PATH, 'utf8')),
|
|
73
|
+
{ updateIntervalDays: 5, showSidebarEntry: false, announceAccessPolicy: false })
|
|
74
|
+
// A boot-time layer must never claim a field, so persisting stays opt-in.
|
|
75
|
+
assert.equal(options.applyPatch({ updateIntervalDays: 6 }), true)
|
|
76
|
+
assert.deepEqual(JSON.parse(readFileSync(PREFS_PATH, 'utf8')),
|
|
77
|
+
{ updateIntervalDays: 5, showSidebarEntry: false, announceAccessPolicy: false })
|
|
78
|
+
assert.equal(options.applyPatch({ updateIntervalDays: 5 }), true)
|
|
79
|
+
|
|
80
|
+
// A fresh module instance models a restart: prefs outranks the env seed.
|
|
81
|
+
const restarted = await import('../lib/options.mjs?options3=' + Date.now())
|
|
82
|
+
assert.equal(restarted.get('updateIntervalDays'), 5)
|
|
83
|
+
assert.equal(restarted.get('showSidebarEntry'), false)
|
|
84
|
+
assert.equal(restarted.get('announceAccessPolicy'), false)
|
|
85
|
+
assert.equal(restarted.get('dbhubPackage'), 'test/fork-package')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('the sidebar preference survives a restart and is dropped only when non-boolean', async () => {
|
|
89
|
+
// The user's "hide the shortcut" choice is a real preference again (it was
|
|
90
|
+
// briefly removed in dev.1/dev.2 and is back in dev.3): a stored `false` must
|
|
91
|
+
// read back as `false`, not fall back to the default.
|
|
92
|
+
writeFileSync(PREFS_PATH, JSON.stringify({ updateIntervalDays: 5, showSidebarEntry: false }))
|
|
93
|
+
const fresh = await import('../lib/options.mjs?options8=' + Date.now())
|
|
94
|
+
assert.equal(fresh.get('updateIntervalDays'), 5)
|
|
95
|
+
assert.equal(fresh.get('showSidebarEntry'), false)
|
|
96
|
+
// A non-boolean is dropped (and the file self-heals) instead of being coerced.
|
|
97
|
+
writeFileSync(PREFS_PATH, JSON.stringify({ updateIntervalDays: 5, showSidebarEntry: 'nope' }))
|
|
98
|
+
const cleaned = await import('../lib/options.mjs?options8b=' + Date.now())
|
|
99
|
+
assert.equal(cleaned.get('showSidebarEntry'), true, 'built-in default')
|
|
100
|
+
assert.deepEqual(JSON.parse(readFileSync(PREFS_PATH, 'utf8')), { updateIntervalDays: 5 })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('the legacy settings document only fills fields prefs does not own', async () => {
|
|
104
|
+
// prefs owns the interval; the sidebar switch is NOT in prefs here, so the
|
|
105
|
+
// older document may still claim it (that is what "unclaimed" means).
|
|
106
|
+
writeFileSync(PREFS_PATH, JSON.stringify({ updateIntervalDays: 5 }))
|
|
107
|
+
const fresh = await import('../lib/options.mjs?options4=' + Date.now())
|
|
108
|
+
assert.equal(fresh.initFromLayers({ legacyUser: { updateIntervalDays: 9, showSidebarEntry: false } }), true)
|
|
109
|
+
assert.equal(fresh.get('updateIntervalDays'), 5, 'prefs owns this field')
|
|
110
|
+
assert.equal(fresh.get('showSidebarEntry'), false, 'unclaimed: the legacy layer applies')
|
|
111
|
+
// A malformed layer is ignored entirely.
|
|
112
|
+
assert.equal(fresh.initFromLayers({ legacyUser: ['nope'] }), false)
|
|
113
|
+
assert.equal(fresh.initFromLayers({}), false)
|
|
114
|
+
assert.equal(fresh.initFromLayers(null), false)
|
|
115
|
+
// A legacy layer cannot claim a field prefs DOES own.
|
|
116
|
+
writeFileSync(PREFS_PATH, JSON.stringify({ updateIntervalDays: 5, showSidebarEntry: true }))
|
|
117
|
+
const owned = await import('../lib/options.mjs?options4b=' + Date.now())
|
|
118
|
+
assert.equal(owned.initFromLayers({ legacyUser: { updateIntervalDays: 9, showSidebarEntry: false } }), false)
|
|
119
|
+
assert.equal(owned.get('updateIntervalDays'), 5)
|
|
120
|
+
assert.equal(owned.get('showSidebarEntry'), true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('a corrupt prefs file cannot seed an invalid option', async () => {
|
|
124
|
+
writeFileSync(PREFS_PATH, JSON.stringify({ updateIntervalDays: -4, showSidebarEntry: 'yes', announceAccessPolicy: 'nope', junk: 1 }))
|
|
125
|
+
const fresh = await import('../lib/options.mjs?options5=' + Date.now())
|
|
126
|
+
assert.equal(fresh.get('updateIntervalDays'), 3) // falls back to the env seed
|
|
127
|
+
assert.equal(fresh.get('showSidebarEntry'), true) // built-in default
|
|
128
|
+
assert.equal(fresh.get('announceAccessPolicy'), true) // built-in default (ON)
|
|
129
|
+
assert.deepEqual(fresh.snapshot(), { updateIntervalDays: 3, showSidebarEntry: true, announceAccessPolicy: true })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('configValuesOf reads the declared Config in both runtime shapes', async () => {
|
|
133
|
+
const fresh = await import('../lib/options.mjs?options6=' + Date.now())
|
|
134
|
+
// dsh 0.1.7+/0.2.x: `.volatile()` fields arrive as live references.
|
|
135
|
+
assert.deepEqual(
|
|
136
|
+
fresh.configValuesOf({ updateIntervalDays: { get: () => 4 }, showSidebarEntry: { get: () => false }, announceAccessPolicy: { get: () => false } }),
|
|
137
|
+
{ updateIntervalDays: 4, showSidebarEntry: false, announceAccessPolicy: false },
|
|
138
|
+
)
|
|
139
|
+
// dsh 0.1.6 (schemastery 3.18.2 has no `.volatile()`): plain values.
|
|
140
|
+
assert.deepEqual(
|
|
141
|
+
fresh.configValuesOf({ updateIntervalDays: 2, showSidebarEntry: true, announceAccessPolicy: true }),
|
|
142
|
+
{ updateIntervalDays: 2, showSidebarEntry: true, announceAccessPolicy: true },
|
|
143
|
+
)
|
|
144
|
+
// Unset, invalid and junk input is skipped — the field keeps its other layers.
|
|
145
|
+
assert.deepEqual(fresh.configValuesOf({ updateIntervalDays: { get: () => undefined }, showSidebarEntry: { get: () => 'yes' } }), {})
|
|
146
|
+
assert.deepEqual(fresh.configValuesOf({ updateIntervalDays: -1, showSidebarEntry: null, announceAccessPolicy: 'yes' }), {})
|
|
147
|
+
assert.deepEqual(fresh.configValuesOf({ unknownKey: 5 }), {})
|
|
148
|
+
assert.deepEqual(fresh.configValuesOf(undefined), {})
|
|
149
|
+
assert.deepEqual(fresh.configValuesOf('nope'), {})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('an adopted Config value claims the field and outlives the config edit', async () => {
|
|
153
|
+
const fresh = await import('../lib/options.mjs?options7=' + Date.now())
|
|
154
|
+
const patch = fresh.configValuesOf({ updateIntervalDays: { get: () => 11 }, showSidebarEntry: { get: () => false }, announceAccessPolicy: { get: () => false } })
|
|
155
|
+
assert.equal(fresh.applyPatch(patch, { persist: true }), true)
|
|
156
|
+
assert.deepEqual(JSON.parse(readFileSync(PREFS_PATH, 'utf8')),
|
|
157
|
+
{ updateIntervalDays: 11, showSidebarEntry: false, announceAccessPolicy: false })
|
|
158
|
+
// Adopting the same value again is a no-op (no write, no republish loop).
|
|
159
|
+
assert.equal(fresh.applyPatch(fresh.configValuesOf({ updateIntervalDays: { get: () => 11 }, showSidebarEntry: { get: () => false }, announceAccessPolicy: { get: () => false } }), { persist: true }), false)
|
|
160
|
+
})
|