Files
dsh-recall/test/config.test.js
T
NotExpectedYet 040c69b57d fix!: restore the settings page against the harness 0.1.7 plugin contracts
The configuration page registered into seats that 0.1.7 removed, so it could
not appear at all — and the schema carried none of the metadata the new
settings document requires.

Host half. `ctx.settings.installSection` no longer exists: a plugin exports a
`Config` schema, the Harness keys one form per composed entry by that row's
`id`, and `ctx.settings.describe` skips an entry with no `volatile()` field
outright. Every field is now volatile, which is also what makes a saved edit
apply to the running instance instead of remounting it — so `apply` reads the
section through `settingsSnapshot`, which unwraps the live references a
volatile field arrives as. Writes go through `ctx.settings.update` fenced by
the revision `describe` reports. The `.min`/`.max` bounds the schema can state
are stated there, so the form refuses an impossible draft in front of the
person typing it; `validateSettings` keeps the cross-field rules no single
field can express, and now runs on the section a call is about to act on
rather than at write time, because 0.1.7 has no write-time validator hook.

Browser half. `settingsScope` and the `settings.plugin.item` slot are gone. The
page renders through `plugins.row.config`, keyed `<bundle package>#<row id>`
(`dsh-recall#dsh-recall`), which is what puts a Configure control on the
plugin's row in the Plugins panel. It uses the framework's own staged form —
`SettingsFormModel` over `ctx.configForms`, drawn by `SettingsForm` and
`SettingsValueField`/`SettingsSecretField` — instead of reproducing card chrome
by hand, so the unsaved, overridden, invalid, and save states come from the
same code as every other plugin's page. All 33 settings are editable now; the
old card exposed 23. The right-sidebar tab also needed the stage-one tab TYPE
(`ctx.sidebarRightTabs.register`) that its keyed body slot is dispatched under,
so a tab can finally be opened. `--dsh-border`, defined nowhere, becomes the
product's `--dsw-alias-border-l2`.

Both halves are now pinned by tests: every field is volatile, the field list
and the schema cannot drift, the page claims the key the patch declares, and a
blank API key is never written — the secret control reports configured-and-
absent instead.

Verified against the harness rather than only in unit tests: `--dump-config-
schema` emits the volatile and secret metadata, `apply` registers `recall` on
the real tool registry, `settings.describe({redactSecrets:true})` serves
`dsh-recall` with 33 fields and the key reported set-but-redacted, and a
scratch profile boots the web app with the plugin composed and its bundle
served byte-for-byte as this checkout has it.
2026-09-26 06:37:57 +01:00

159 lines
7.9 KiB
JavaScript

import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { Config, SETTING_KEYS, configValue, settingsSnapshot, validateSettings } from '../index.js'
/**
* The load-time contract, and the shape settings arrive in.
*
* `apply` never runs when Cordis rejects a plugin's `Config`, so these are the
* only failures a plugin cannot report from inside itself — the profile simply
* refuses to boot. The rest of the suite calls `apply` directly and would never
* notice, which is exactly why this file exists separately.
*
* There are two contracts here, not one. The first is validation. The second is
* that a field marked `volatile()` arrives as a live reference rather than a
* value — so a schema edit changes both what the plugin validates against and
* how it must be read. Both are asserted, because getting the second wrong
* looks like a config of `undefined`s rather than like an error.
*/
test('Config is a Standard Schema, which is what Cordis requires', () => {
assert.ok(Config !== undefined, 'Config must be exported')
assert.ok(Config['~standard'] !== undefined, 'Config must implement the Standard Schema interface')
})
/** The section as the plugin reads it: schema-resolved, then unwrapped. */
function resolve(patch = {}) {
return settingsSnapshot(Config(patch))
}
test('Config fills every field from an empty input, so apply can rely on defaults', () => {
const filled = Config({})
for (const key of SETTING_KEYS) {
assert.ok(key in filled, `${key} has no default and would be undefined in apply`)
}
const resolved = settingsSnapshot(filled)
assert.equal(resolved.embeddingApiBase, 'http://127.0.0.1:11434')
assert.equal(resolved.embeddingDimensions, 768)
assert.equal(resolved.syncBudgetMs, 20_000)
assert.deepEqual(resolved.exclude, [])
assert.deepEqual(resolved.extensions, [])
})
test('Config keeps an explicit value and fills the rest', () => {
const resolved = resolve({ embeddingApiBase: 'http://10.50.1.2:11434', embeddingConcurrency: 8 })
assert.equal(resolved.embeddingApiBase, 'http://10.50.1.2:11434')
assert.equal(resolved.embeddingConcurrency, 8)
assert.equal(resolved.embeddingBatchSize, 16)
assert.equal(resolved.embeddingTimeoutMs, 30_000)
assert.equal(resolved.embeddingKeepAlive, '5m')
// The per-excerpt cap equals the chunk cap, so chunks arrive whole.
assert.equal(resolved.excerptLines, 80)
assert.equal(resolved.totalExcerptLines, 400)
assert.equal(resolved.maxChunkLines, 80)
})
test('a wrongly typed value fails loudly at load rather than silently in apply', () => {
assert.throws(() => Config({ embeddingDimensions: 'seven hundred' }))
assert.throws(() => Config({ exclude: 'src/generated/' }))
assert.throws(() => Config({ embeddingApiBase: { host: 'localhost' } }))
})
test('a value outside a field range is refused by the schema, not just by validateSettings', () => {
// These bounds are duplicated in validateSettings on purpose, but the copy
// that matters most is this one: it is what the generated settings form
// validates a draft against before the Host is ever asked to store it.
assert.throws(() => Config({ minSimilarity: 1.5 }), /<= 1/)
assert.throws(() => Config({ chunkLines: 0 }), />= 1/)
assert.throws(() => Config({ overlapLines: -1 }), />= 0/)
assert.throws(() => Config({ embeddingDimensions: -1 }), />= 1/)
})
test('every declared field is volatile, or the settings surface is not served', () => {
// `ctx.settings.describe` skips an entry with no volatile field, so this is
// the difference between a configuration page and no configuration page —
// not between a full page and an empty one.
const envelope = JSON.parse(JSON.stringify(Config.toJSON()))
const root = envelope.refs[String(envelope.uid)]
const notVolatile = Object.entries(root.dict)
.filter(([, uid]) => envelope.refs[String(uid)].meta?.volatile !== true)
.map(([key]) => key)
assert.deepEqual(notVolatile, [], 'every field must be volatile')
})
test('a volatile field reads through configValue; a plain one is handed back', () => {
const filled = Config({ embeddingApiBase: 'http://stub' })
assert.equal(typeof filled.embeddingApiBase, 'object', 'a volatile field arrives as a reference')
assert.equal(configValue(filled.embeddingApiBase), 'http://stub')
// A test (or any caller) that hands `apply` a plain resolved object must keep
// working: the same reader serves both shapes.
assert.equal(configValue('http://plain'), 'http://plain')
assert.equal(configValue(undefined), undefined)
assert.equal(configValue(0), 0)
})
test('a snapshot tracks a live edit, because volatile references update in place', () => {
const filled = Config({ embeddingConcurrency: 2 })
assert.equal(settingsSnapshot(filled).embeddingConcurrency, 2)
// This is what the Loader does on a volatile-only config change: write the new
// value into the reference the running instance already holds.
filled.embeddingConcurrency[Object.getOwnPropertySymbols(filled.embeddingConcurrency)[0]](8)
assert.equal(settingsSnapshot(filled).embeddingConcurrency, 8)
})
test('every declared setting is a real schema key, so none is dead config', () => {
const filled = Config({})
for (const key of SETTING_KEYS) {
assert.ok(Object.hasOwn(filled, key), `${key} is listed in SETTING_KEYS but is not in the schema`)
}
// And the reverse: no schema field is missing from the documented list, or
// `configure` would reject a setting the schema happily accepts.
for (const key of Object.keys(filled)) {
assert.ok(SETTING_KEYS.includes(key), `${key} is in the schema but not in SETTING_KEYS`)
}
})
/**
* A plain section with an override laid over the resolved defaults.
*
* Deliberately built WITHOUT going back through `Config`: these tests are about
* the rules `validateSettings` owns, and half of them are also schema bounds —
* so parsing them would fail at the schema and never reach the rule under test.
* The schema's own refusal of the same values is asserted separately above.
*/
function settingsWith(patch) {
return { ...settingsSnapshot(Config({})), ...patch }
}
test('validateSettings accepts the defaults', () => {
assert.doesNotThrow(() => validateSettings(resolve()))
})
test('validateSettings rejects an endpoint that is not a URL', () => {
// A bare host:port parses successfully — `localhost:` is read as the scheme —
// so it is the protocol check that actually catches the common mistake.
assert.throws(() => validateSettings(settingsWith({ embeddingApiBase: 'localhost:11434' })), /must be http or https/)
assert.throws(() => validateSettings(settingsWith({ embeddingApiBase: '' })), /not a URL/)
assert.doesNotThrow(() => validateSettings(settingsWith({ embeddingApiBase: 'http://10.50.1.2:11434' })))
})
test('validateSettings rejects a non-http protocol', () => {
assert.throws(() => validateSettings(settingsWith({ embeddingApiBase: 'file:///tmp/socket' })), /must be http or https/)
})
test('validateSettings rejects values that would break the pipeline', () => {
assert.throws(() => validateSettings(settingsWith({ embeddingDimensions: 0 })), /positive number/)
assert.throws(() => validateSettings(settingsWith({ embeddingConcurrency: 0 })), /at least 1/)
assert.throws(() => validateSettings(settingsWith({ excerptLines: 0 })), /at least 1/)
assert.throws(() => validateSettings(settingsWith({ syncBudgetMs: -1 })), /cannot be negative/)
})
test('validateSettings rejects cross-field contradictions the schema cannot see', () => {
assert.throws(() => validateSettings(settingsWith({ defaultLimit: 50, maxLimit: 10 })), /cannot exceed maxLimit/)
assert.throws(() => validateSettings(settingsWith({ chunkLines: 100, maxChunkLines: 80 })), /below chunkLines/)
// An overlap at or above the cap means a split never advances.
assert.throws(() => validateSettings(settingsWith({ chunkLines: 10, maxChunkLines: 10, overlapLines: 10 })), /never advances/)
assert.throws(() => validateSettings(settingsWith({ minSimilarity: 1.5 })), /between 0 and 1/)
})