Files
dsh-recall/test/config.test.js
T
NotExpectedYet a48e05585e fix: remove one machine's addresses and paths from the shipped defaults
The plugin was developed against an Ollama on a private LAN address, and that
address had reached places that ship:

- the `config` parameter description the MODEL reads on every request;
- the commented example in `cordis.patch.yml`, the layer every profile applies;
- `scripts/smoke.mjs` and `scripts/measure.mjs` endpoint defaults;
- both measure scripts' default workspace, which was an absolute local path
  and named a private repository;
- README examples and test fixtures.

All of them now use `http://127.0.0.1:11434`, which is Ollama's documented
default and belongs to no particular machine — kept rather than emptied because
`validateSettings` requires an http(s) URL, so an empty default would turn every
call into INVALID_SETTINGS until someone configured it. Test fixtures use
192.0.2.10 instead: TEST-NET-1 is reserved for documentation and cannot point at
a host anybody runs. The `focus` and `config` examples are generic, and the
workspace defaults are the working directory.

Two script bugs surfaced once the defaults stopped pointing at a live box, and
are fixed here. `measure.mjs` built its own configuration literal, which was ten
fields short of the schema — harmless while nothing validated it, and a refused
call the moment `execute` began checking the section it was handed; it now
resolves its configuration through the plugin's own `Config`, so it cannot drift
again. It also crashed on a failed search (`first.index.scanned` of a result
that has no index) instead of reporting the endpoint it could not reach, which
is now the ordinary case. `scope-measure.mjs` divided by a scope that does not
exist in any workspace without focus markers, and now says so.

Also corrects `cordis.patch.yml`'s description of what the row registers: in
0.1.7 the exported `Config` is the settings registration, not a namespace on
`ctx.settings`.
2026-09-26 06:47:25 +01:00

161 lines
8.1 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', () => {
// A TEST-NET-1 address (RFC 5737): reserved for documentation, so a fixture
// can never point at a host anybody actually runs.
const resolved = resolve({ embeddingApiBase: 'http://192.0.2.10:11434', embeddingConcurrency: 8 })
assert.equal(resolved.embeddingApiBase, 'http://192.0.2.10: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://192.0.2.10: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/)
})