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`.
161 lines
8.1 KiB
JavaScript
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/)
|
|
})
|