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/) })