Files
dsh-slopo/test/config.test.js
dsh-slopo agent 8f68ef4e37 fix: read the settings values, not the references they arrive as
Every field is `volatile()`, which is what puts the settings card on the
Plugins page — and what makes the resolved config carry a reference per field
rather than a value. Every read was a plain-value test (`typeof x === 'string'`,
`Number(x)`, `x !== true`), so all of them were false and the plugin behaved as
if nothing had been configured.

The card displayed and persisted correctly the whole time, because the settings
wire unwraps references for display; only the plugin's own reads were broken.
Reads now go through `configValue()`, and the tool takes a fresh snapshot per
call, which is what lets an edit reach a running instance.
2026-09-26 07:13:36 +01:00

201 lines
9.0 KiB
JavaScript

import assert from 'node:assert/strict'
import { test } from 'node:test'
import { Config, configValue, settingsSnapshot, toSlopoConfig, workspacePaths } from '../index.js'
import {
SLOPO_CONFIG_KEYS,
missingRequired,
normalizeConfig,
pruneConfig,
renderSlopoConfig,
} from '../lib/config.js'
test('pruneConfig keeps only known keys and drops empties', () => {
const pruned = pruneConfig({
source_dir: '/src',
embedding_model: '',
embedding_dimensions: 0,
source_dir_exclude: [],
unknown_key: 'nope',
embedding_params: {},
})
assert.deepEqual(Object.keys(pruned), ['source_dir', 'embedding_dimensions'])
assert.equal(pruned.source_dir, '/src')
// 0 is a real value, not an empty one.
assert.equal(pruned.embedding_dimensions, 0)
})
test('pruneConfig silently refuses a key slopo would hard-error on', () => {
const pruned = pruneConfig({ source_dir: '/src', totally_unknown: 'x' })
assert.equal(Object.hasOwn(pruned, 'totally_unknown'), false)
for (const key of Object.keys(pruned)) assert.ok(SLOPO_CONFIG_KEYS.includes(key))
})
test('renderSlopoConfig emits key: value with a space, which slopo lints for', () => {
const text = renderSlopoConfig({ source_dir: '/a b/c', embedding_dimensions: 768 })
assert.match(text, /^source_dir: "\/a b\/c"$/m)
assert.match(text, /^embedding_dimensions: 768$/m)
for (const line of text.split('\n')) {
if (line === '' || line.startsWith('#') || line.startsWith(' ')) continue
assert.doesNotMatch(line, /^[A-Za-z_][A-Za-z0-9_]*:\S/, `no-space-after-colon: ${line}`)
}
})
test('renderSlopoConfig always quotes strings so a colon or dash cannot change meaning', () => {
const text = renderSlopoConfig({
embedding_model: 'ollama/model:latest',
db_file: '/tmp/-weird#path/x.db',
})
assert.match(text, /^embedding_model: "ollama\/model:latest"$/m)
assert.match(text, /^db_file: "\/tmp\/-weird#path\/x\.db"$/m)
})
test('renderSlopoConfig escapes a quote rather than emitting broken YAML', () => {
const text = renderSlopoConfig({ source_dir: '/a"b' })
assert.match(text, /^source_dir: "\/a\\"b"$/m)
})
test('renderSlopoConfig renders lists and nested maps', () => {
const text = renderSlopoConfig({
source_dir_exclude: ['**/test/**', '*.min.js'],
embedding_params: { api_base: 'http://127.0.0.1:11434' },
})
assert.match(text, /^source_dir_exclude:\n {2}- "\*\*\/test\/\*\*"\n {2}- "\*\.min\.js"$/m)
assert.match(text, /^embedding_params:\n {2}api_base: "http:\/\/127\.0\.0\.1:11434"$/m)
})
test('renderSlopoConfig never writes the embedding API key', () => {
const text = renderSlopoConfig({ source_dir: '/src', embedding_api_key: 'sk-secret' })
assert.doesNotMatch(text, /sk-secret/)
assert.doesNotMatch(text, /embedding_api_key/)
})
test('renderSlopoConfig refuses to silently write a non-finite number', () => {
assert.throws(() => renderSlopoConfig({ embedding_dimensions: Number.NaN }), TypeError)
})
test('missingRequired reports exactly the three keys slopo refuses to start without', () => {
assert.deepEqual(missingRequired({}), ['source_dir', 'embedding_model', 'embedding_dimensions'])
assert.deepEqual(missingRequired({ source_dir: '/src', embedding_model: 'm', embedding_dimensions: 768 }), [])
// A dimension of 0 counts as unset, not as configured.
assert.deepEqual(missingRequired({ source_dir: '/s', embedding_model: 'm', embedding_dimensions: 0 }), [
'embedding_dimensions',
])
})
test('normalizeConfig coerces numeric strings from a settings form', () => {
const { config, problems } = normalizeConfig({ embedding_dimensions: '768', embedding_batch_size: '50' })
assert.deepEqual(problems, [])
assert.equal(config.embedding_dimensions, 768)
assert.equal(config.embedding_batch_size, 50)
})
test('normalizeConfig rejects a non-numeric value instead of writing a corrupt config', () => {
const { config, problems } = normalizeConfig({ embedding_dimensions: 'lots' })
assert.equal(Object.hasOwn(config, 'embedding_dimensions'), false)
assert.equal(problems.length, 1)
assert.match(problems[0], /embedding_dimensions must be a number/)
})
test('normalizeConfig keeps thresholds fractional but rejects fractional counts', () => {
const ok = normalizeConfig({ review_similarity_threshold: 0.92 })
assert.equal(ok.config.review_similarity_threshold, 0.92)
assert.deepEqual(ok.problems, [])
const bad = normalizeConfig({ embedding_batch_size: 12.5 })
assert.equal(Object.hasOwn(bad.config, 'embedding_batch_size'), false)
assert.match(bad.problems[0], /whole number/)
})
/**
* The other half of the config contract: the shape a section ARRIVES in.
*
* Every field of `Config` is `volatile()`, so Cordis hands `apply` a reference
* object per field rather than a value — that is what lets a settings edit land
* without re-applying the plugin. A plain-value read of such a field is silently
* false, which is how this plugin came to ignore its own settings and fall back
* to autodiscovery. These tests resolve through the real schema path, because a
* fixture built from plain literals can never catch that.
*/
/** The section as the Loader builds it: schema-resolved, references intact. */
function resolved(patch = {}) {
const result = Config['~standard'].validate(patch)
assert.equal(result.issues, undefined, `the schema refused the fixture: ${JSON.stringify(result.issues)}`)
return result.value
}
test('a volatile field arrives as a reference, and configValue unwraps it', () => {
const config = resolved({ embeddingApiBase: 'http://192.0.2.10:11434' })
assert.equal(typeof config.embeddingApiBase, 'object', 'a volatile field is a reference, not a value')
assert.equal(configValue(config.embeddingApiBase), 'http://192.0.2.10:11434')
// …and a plain value is handed straight back, so a caller that passes a
// literal object — every other test in this suite, and `apply` in a profile
// whose fields are not volatile — takes the very same code path.
assert.equal(configValue('http://plain'), 'http://plain')
assert.equal(configValue(undefined), undefined)
assert.equal(configValue(0), 0)
})
test('a snapshot reads the configured values out of a schema-resolved section', () => {
const settings = settingsSnapshot(
resolved({
embeddingModel: 'ollama/example:latest',
embeddingDimensions: 768,
embeddingRequestDelay: 25,
embeddingParamsJson: '{"keep_alive":"5m"}',
}),
)
assert.equal(settings.embeddingModel, 'ollama/example:latest')
assert.equal(settings.embeddingDimensions, 768)
assert.equal(settings.embeddingRequestDelay, 25)
assert.equal(settings.embeddingParamsJson, '{"keep_alive":"5m"}')
// The fields left unset are still the schema's own defaults, not `undefined`.
assert.equal(settings.sourceDir, '')
assert.deepEqual(settings.sourceDirExclude, [])
})
test('toSlopoConfig turns a schema-resolved section into slopo config, not into defaults', () => {
// The regression: every read in this path was a plain-value check, so the
// model read as '' and the dimensions as undefined — the tool reported itself
// unconfigured while the settings card was displaying a configured one.
const config = resolved({
embeddingModel: 'ollama/example:latest',
embeddingDimensions: 768,
embeddingRequestDelay: 25,
embeddingApiBase: 'http://192.0.2.10:11434',
embeddingParamsJson: '{"keep_alive":"5m"}',
sourceDir: '/src',
})
const paths = workspacePaths('/home/dsh', '/work', config)
assert.equal(paths.sourceDir, '/src')
const problems = []
const slopoConfig = toSlopoConfig(config, paths, problems)
assert.deepEqual(problems, [])
assert.equal(slopoConfig.embedding_model, 'ollama/example:latest')
assert.equal(slopoConfig.embedding_dimensions, 768)
assert.equal(slopoConfig.embedding_request_delay, 25)
assert.deepEqual(slopoConfig.embedding_params, { keep_alive: '5m', api_base: 'http://192.0.2.10:11434' })
})
test('the same helpers still take a plain object, which is how they are called elsewhere', () => {
const paths = workspacePaths('/home/dsh', '/work', { sourceDir: '/src', stateDir: '/state' })
assert.equal(paths.stateDir, '/state')
assert.equal(paths.sourceDir, '/src')
const slopoConfig = toSlopoConfig({ embeddingModel: 'ollama/plain:latest', embeddingDimensions: 1024 }, paths)
assert.equal(slopoConfig.embedding_model, 'ollama/plain:latest')
assert.equal(slopoConfig.embedding_dimensions, 1024)
})
test('a snapshot taken per call sees a live edit, because the reference is written in place', () => {
const config = resolved({ embeddingDimensions: 768 })
assert.equal(settingsSnapshot(config).embeddingDimensions, 768)
// This is what the Loader does on a volatile-only settings change: it writes
// the new value into the reference the running instance already holds, without
// re-applying the plugin. A snapshot cached at apply time would never see it,
// which is why the tool takes a fresh one per call.
const [write] = Object.getOwnPropertySymbols(config.embeddingDimensions)
config.embeddingDimensions[write](1024)
assert.equal(settingsSnapshot(config).embeddingDimensions, 1024)
})