Test the plugin against the real harness seams and the real CLI
The integration tests load the real plugin module against a thin mock Cordis
context and drive the tool through a real shell into the real slopo binary. The
mock implements only the seams the plugin touches, so a wrong assumption about a
dsh service fails a test instead of producing a plugin that silently does
nothing at runtime.
Two real bugs were caught this way and are now pinned:
* renderSlopoConfig would write the embedding API key to disk if it were
handed one. It cannot now.
* --config-version was placed before the subcommand, which slopo rejects with
exit code 2. It is declared on the command, not the callback.
The end-to-end test is opt-in (DSH_SLOPO_E2E=1) because it needs a reachable
embedding backend; it indexes a real repository, embeds it, and asserts a real
duplicate cluster comes back.
This commit is contained in:
1 parent
31991143e4
commit
3373e0b4f6
4 files changed
+610
-1
No files matched your search
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
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/)
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Integration tests: load the real plugin module against a mock Cordis context,
|
||||
* then drive the tool through a real shell into the real slopo CLI.
|
||||
*
|
||||
* The mock is deliberately thin — it implements only the seams the plugin
|
||||
* actually touches — so a wrong assumption about a dsh service shows up here as
|
||||
* a failing test rather than as a plugin that silently does nothing at runtime.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { exec } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { findSlopo } from '../lib/slopo.js'
|
||||
|
||||
const testHome = mkdtempSync(join(tmpdir(), 'dsh-slopo-home-'))
|
||||
process.env.DSH_HOME = testHome
|
||||
|
||||
const { Config, apply } = await import('../index.js')
|
||||
|
||||
const FULL_CONFIG = {
|
||||
binary: '',
|
||||
autoInstall: true,
|
||||
embeddingModel: '',
|
||||
embeddingDimensions: 0,
|
||||
embeddingApiBase: '',
|
||||
embeddingApiKey: '',
|
||||
embeddingParamsJson: '{}',
|
||||
sourceDir: '',
|
||||
sourceDirExclude: [],
|
||||
ignoreFile: '',
|
||||
stateDir: '',
|
||||
embeddingRequestDelay: 0,
|
||||
timeoutMs: 600_000,
|
||||
}
|
||||
|
||||
/** A shell executor that really runs the command. */
|
||||
const shell = {
|
||||
sandboxMode: undefined,
|
||||
resolve: (request) => ({
|
||||
workdir: process.cwd(),
|
||||
timeoutMs: 600_000,
|
||||
stdoutMaxBytes: 10_000_000,
|
||||
...request,
|
||||
}),
|
||||
run(spec) {
|
||||
return new Promise((resolve) => {
|
||||
exec(
|
||||
spec.command,
|
||||
{ cwd: spec.workdir, env: { ...process.env, ...(spec.env ?? {}) }, timeout: spec.timeoutMs, maxBuffer: 64 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => {
|
||||
resolve({
|
||||
exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : 1,
|
||||
signal: null,
|
||||
timedOut: Boolean(error?.killed),
|
||||
aborted: false,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: { text: String(stdout), truncated: false },
|
||||
stderr: { text: String(stderr), truncated: false },
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
},
|
||||
start() {
|
||||
throw new Error('background process start is not used by these tests')
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a context exposing exactly the services the plugin consumes.
|
||||
*
|
||||
* `jobs` is deliberately absent, which forces the tool down its foreground path
|
||||
* so the assertions can see a result rather than a job handle.
|
||||
*/
|
||||
function harness(overrides = {}, { withSettings = true, withJobs = false } = {}) {
|
||||
const config = { ...FULL_CONFIG, ...overrides }
|
||||
const state = { tools: new Map(), updates: [], provider: undefined, namespace: undefined, shell, jobs: undefined }
|
||||
|
||||
const ctx = {
|
||||
logger: () => ({ info() {}, warn() {}, debug() {}, error() {} }),
|
||||
get(name) {
|
||||
if (name === 'shell') return shell
|
||||
if (name === 'jobs') return withJobs ? state.jobs : undefined
|
||||
return undefined
|
||||
},
|
||||
inject(deps, callback) {
|
||||
if (!withSettings || !deps.includes('settings')) return
|
||||
callback({
|
||||
settings: {
|
||||
installSection(owner, namespace, schema, entry, hooks) {
|
||||
state.namespace = namespace
|
||||
state.schema = schema
|
||||
hooks.setSource(() => config)
|
||||
},
|
||||
async update(namespace, patch) {
|
||||
state.updates.push({ namespace, patch })
|
||||
Object.assign(config, patch)
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
skills: {
|
||||
registerProvider(create) {
|
||||
state.provider = create({ signal: new AbortController().signal, invalidate() {} })
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
register(definition) {
|
||||
state.tools.set(definition.name, definition)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
apply(ctx, config)
|
||||
return { config, state }
|
||||
}
|
||||
|
||||
const execContext = (cwd) => ({ signal: new AbortController().signal, agent: { session: { header: { cwd } } } })
|
||||
|
||||
test('Config is a schemastery schema, which is what cordis requires', () => {
|
||||
assert.equal(typeof Config, 'function')
|
||||
assert.ok(Config['~standard'] !== undefined, 'expected a Standard Schema')
|
||||
})
|
||||
|
||||
test('apply registers exactly one tool, and it is called slopo', () => {
|
||||
const { state } = harness()
|
||||
assert.deepEqual([...state.tools.keys()], ['slopo'])
|
||||
})
|
||||
|
||||
test('the ported skills are published user-invocable and NOT model-invocable', async () => {
|
||||
const { state } = harness()
|
||||
const candidates = await state.provider.list()
|
||||
assert.deepEqual(
|
||||
candidates.map((c) => c.name),
|
||||
['slopo-review', 'slopo-analyze-ignore', 'slopo-analyze-one'],
|
||||
)
|
||||
for (const candidate of candidates) {
|
||||
assert.equal(candidate.invocation.userInvocable, true, candidate.name)
|
||||
assert.equal(candidate.invocation.modelInvocable, false, candidate.name)
|
||||
assert.equal(candidate.rank, 600)
|
||||
assert.equal(candidate.source, 'bundled')
|
||||
assert.ok(candidate.description.length > 20, candidate.name)
|
||||
assert.ok(candidate.locator !== undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('get() returns the skill body, routed through the locator list() issued', async () => {
|
||||
const { state } = harness()
|
||||
const [candidate] = await state.provider.list()
|
||||
const skill = await state.provider.get(candidate)
|
||||
assert.equal(skill.name, 'slopo-review')
|
||||
assert.match(skill.content, /# Instructions/)
|
||||
assert.match(skill.content, /First-turn output rules/)
|
||||
assert.match(skill.content, /slopo.*action: "review"/s)
|
||||
})
|
||||
|
||||
test('every skill tells the agent to call the tool rather than a shell command', async () => {
|
||||
const { state } = harness()
|
||||
for (const candidate of await state.provider.list()) {
|
||||
const skill = await state.provider.get(candidate)
|
||||
assert.match(skill.content, /`slopo` tool/, candidate.name)
|
||||
// The Claude Code bash-injection directive must not survive the port.
|
||||
assert.doesNotMatch(skill.content, /!`/, candidate.name)
|
||||
assert.doesNotMatch(skill.content, /allowed-tools/, candidate.name)
|
||||
}
|
||||
})
|
||||
|
||||
test('the settings namespace is registered under a grammar-valid name', () => {
|
||||
const { state } = harness()
|
||||
assert.match(state.namespace, /^[a-z][a-z0-9-]*$/)
|
||||
})
|
||||
|
||||
test('status reports the missing settings instead of pretending to be ready', async () => {
|
||||
const { state } = harness()
|
||||
const tool = state.tools.get('slopo')
|
||||
const value = await tool.execute({ action: 'status' }, execContext('/tmp'))
|
||||
assert.equal(value.kind, 'foreground')
|
||||
assert.equal(value.ok, false)
|
||||
assert.deepEqual(value.details.missingSettings, ['embedding_model', 'embedding_dimensions'])
|
||||
assert.match(value.message, /Not configured yet/)
|
||||
})
|
||||
|
||||
test('status resolves state under $DSH_HOME, never inside the workspace', async () => {
|
||||
const { state } = harness()
|
||||
const tool = state.tools.get('slopo')
|
||||
const value = await tool.execute({ action: 'status' }, execContext('/tmp/some-repo'))
|
||||
assert.ok(value.details.stateDir.startsWith(testHome), value.details.stateDir)
|
||||
assert.doesNotMatch(value.details.stateDir, /^\/tmp\/some-repo/)
|
||||
})
|
||||
|
||||
test('configure rejects a setting this plugin does not have', async () => {
|
||||
const { state } = harness()
|
||||
const tool = state.tools.get('slopo')
|
||||
const value = await tool.execute({ action: 'configure', config: { embeddingModel: 'x', nope: 1 } }, execContext('/tmp'))
|
||||
assert.equal(value.ok, false)
|
||||
assert.equal(value.errorCode, 'UNKNOWN_SETTING')
|
||||
assert.match(value.message, /nope/)
|
||||
assert.deepEqual(state.updates, [])
|
||||
})
|
||||
|
||||
test('configure persists through the settings namespace, not a private file', async () => {
|
||||
const { state } = harness()
|
||||
const tool = state.tools.get('slopo')
|
||||
const value = await tool.execute(
|
||||
{ action: 'configure', config: { embeddingModel: 'ollama/m:latest', embeddingDimensions: 768 } },
|
||||
execContext('/tmp'),
|
||||
)
|
||||
assert.equal(value.ok, true)
|
||||
assert.equal(state.updates.length, 1)
|
||||
assert.equal(state.updates[0].namespace, 'dsh-slopo')
|
||||
assert.deepEqual(state.updates[0].patch, { embeddingModel: 'ollama/m:latest', embeddingDimensions: 768 })
|
||||
})
|
||||
|
||||
test('configure says so plainly when no settings provider is mounted', async () => {
|
||||
const { state } = harness({}, { withSettings: false })
|
||||
const tool = state.tools.get('slopo')
|
||||
const value = await tool.execute({ action: 'configure', config: { embeddingModel: 'x' } }, execContext('/tmp'))
|
||||
assert.equal(value.ok, false)
|
||||
assert.equal(value.errorCode, 'NO_SETTINGS_PROVIDER')
|
||||
})
|
||||
|
||||
test('the plugin still loads and registers its tool without a settings provider', () => {
|
||||
// This is the whole reason `settings` goes through ctx.inject rather than the
|
||||
// top-level inject: a required dependency would have suppressed apply entirely.
|
||||
const { state } = harness({}, { withSettings: false })
|
||||
assert.ok(state.tools.has('slopo'))
|
||||
assert.ok(state.provider !== undefined)
|
||||
})
|
||||
|
||||
test('review refuses to run before the workspace is configured', async () => {
|
||||
const { state } = harness()
|
||||
const tool = state.tools.get('slopo')
|
||||
const value = await tool.execute({ action: 'review' }, execContext('/tmp'))
|
||||
assert.equal(value.ok, false)
|
||||
assert.equal(value.errorCode, 'NOT_CONFIGURED')
|
||||
assert.match(value.remedy, /configure/)
|
||||
})
|
||||
|
||||
test('review writes a config the plugin owns and passes the key only in the environment', async () => {
|
||||
const { state } = harness({
|
||||
embeddingModel: 'ollama/example:latest',
|
||||
embeddingDimensions: 768,
|
||||
embeddingApiBase: 'http://127.0.0.1:11434',
|
||||
embeddingApiKey: 'super-secret',
|
||||
})
|
||||
const tool = state.tools.get('slopo')
|
||||
const value = await tool.execute({ action: 'review', foreground: true }, execContext('/tmp/nonexistent-repo-xyz'))
|
||||
// It will fail — there is no git repo there — but the failure must be a
|
||||
// reported one, and the config must have been materialised first.
|
||||
assert.equal(value.kind, 'foreground')
|
||||
assert.ok(existsSync(value.configPath), `expected config at ${value.configPath}`)
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const written = readFileSync(value.configPath, 'utf8')
|
||||
assert.match(written, /embedding_model: "ollama\/example:latest"/)
|
||||
assert.doesNotMatch(written, /super-secret/)
|
||||
})
|
||||
|
||||
test('a background-capable profile gets a job handle instead of a blocking call', async () => {
|
||||
const started = []
|
||||
const { state } = harness(
|
||||
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768 },
|
||||
{ withJobs: true },
|
||||
)
|
||||
// ctx.get('jobs') is consulted at call time, not at apply time, so the fake
|
||||
// registry can be installed after the plugin has loaded.
|
||||
state.jobs = {
|
||||
start(spec) {
|
||||
started.push(spec)
|
||||
return 'slopo-1'
|
||||
},
|
||||
}
|
||||
const tool = state.tools.get('slopo')
|
||||
const value = await tool.execute({ action: 'review' }, execContext('/tmp'))
|
||||
assert.equal(value.kind, 'background')
|
||||
assert.equal(value.jobId, 'slopo-1')
|
||||
assert.equal(started.length, 1)
|
||||
assert.equal(started[0].kind, 'slopo')
|
||||
assert.equal(typeof started[0].run, 'function')
|
||||
})
|
||||
|
||||
test(
|
||||
'review runs the real slopo CLI end to end against a real repository',
|
||||
{ skip: process.env.DSH_SLOPO_E2E !== '1' || findSlopo(testHome) === undefined },
|
||||
async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'dsh-slopo-e2e-'))
|
||||
const { execFileSync } = await import('node:child_process')
|
||||
const { mkdirSync } = await import('node:fs')
|
||||
mkdirSync(join(repo, 'src'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(repo, 'src', 'orders.py'),
|
||||
'def total_for_order(order):\n total = 0\n for line in order["lines"]:\n total += line["price"] * line["qty"]\n return round(total, 2)\n',
|
||||
)
|
||||
writeFileSync(
|
||||
join(repo, 'src', 'invoices.py'),
|
||||
'def invoice_total(invoice):\n running = 0\n for item in invoice["items"]:\n running += item["unit_price"] * item["quantity"]\n return round(running, 2)\n',
|
||||
)
|
||||
execFileSync('git', ['init', '-q'], { cwd: repo })
|
||||
execFileSync('git', ['add', '-A'], { cwd: repo })
|
||||
execFileSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init'], { cwd: repo })
|
||||
writeFileSync(
|
||||
join(repo, 'src', 'orders.py'),
|
||||
'def total_for_order(order):\n total = 0\n for line in order["lines"]:\n total += line["price"] * line["qty"]\n return round(total, 2)\n\n\ndef refund_for_order(order):\n amount = 0\n for line in order["lines"]:\n amount += line["price"] * line["qty"]\n return round(amount, 2)\n',
|
||||
)
|
||||
|
||||
const { state } = harness({
|
||||
sourceDir: join(repo, 'src'),
|
||||
embeddingModel: 'ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
|
||||
embeddingDimensions: 768,
|
||||
embeddingApiBase: 'http://10.50.1.2:11434',
|
||||
})
|
||||
const tool = state.tools.get('slopo')
|
||||
|
||||
const prepared = await tool.execute({ action: 'prepare', foreground: true }, execContext(repo))
|
||||
assert.equal(prepared.ok, true, JSON.stringify(prepared))
|
||||
|
||||
const reviewed = await tool.execute({ action: 'review', foreground: true }, execContext(repo))
|
||||
assert.equal(reviewed.ok, true, JSON.stringify(reviewed))
|
||||
assert.match(reviewed.report, /cluster 1/)
|
||||
assert.match(reviewed.report, /\* /)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { renderSlopoConfig } from '../lib/config.js'
|
||||
import {
|
||||
commandLine,
|
||||
explainFailure,
|
||||
findSlopo,
|
||||
installEnv,
|
||||
lastLogError,
|
||||
shellQuote,
|
||||
slopoArgs,
|
||||
slopoCandidates,
|
||||
uvInstallArgs,
|
||||
} from '../lib/slopo.js'
|
||||
import { managedSlopoPath, resolveDshHome, workspaceKey, workspaceStateDir } from '../lib/paths.js'
|
||||
|
||||
test('findSlopo prefers the managed install over PATH', () => {
|
||||
const home = '/dsh'
|
||||
const env = { PATH: '/usr/bin:/bin' }
|
||||
const candidates = slopoCandidates(home, env)
|
||||
const managed = candidates.indexOf(managedSlopoPath(home))
|
||||
const onPath = candidates.indexOf('/usr/bin/slopo')
|
||||
assert.ok(managed >= 0 && onPath >= 0)
|
||||
assert.ok(managed < onPath, 'managed install must be probed before PATH')
|
||||
assert.equal(findSlopo(home, env, (p) => p === '/bin/slopo'), '/bin/slopo')
|
||||
assert.equal(findSlopo(home, env, () => false), undefined)
|
||||
})
|
||||
|
||||
test('findSlopo honours an explicit binary, including one not on PATH', () => {
|
||||
const env = { PATH: '', DSH_SLOPO_BINARY: '/opt/custom/slopo' }
|
||||
assert.equal(findSlopo('/dsh', env, (p) => p === '/opt/custom/slopo'), '/opt/custom/slopo')
|
||||
})
|
||||
|
||||
test('installEnv redirects all three uv directories into the harness home', () => {
|
||||
const env = installEnv('/dsh', { PATH: '/usr/bin' })
|
||||
assert.equal(env.UV_TOOL_DIR, '/dsh/dsh-slopo/uv-tools')
|
||||
assert.equal(env.UV_TOOL_BIN_DIR, '/dsh/dsh-slopo/uv-tools/bin')
|
||||
assert.equal(env.UV_PYTHON_INSTALL_DIR, '/dsh/dsh-slopo/uv-python')
|
||||
// The parent environment must survive, or PATH is lost for the install.
|
||||
assert.equal(env.PATH, '/usr/bin')
|
||||
})
|
||||
|
||||
test('uvInstallArgs pins the interpreter slopo requires', () => {
|
||||
assert.deepEqual(uvInstallArgs(), ['tool', 'install', 'slopo', '--python', '3.12'])
|
||||
assert.deepEqual(uvInstallArgs({ upgrade: true }), ['tool', 'install', 'slopo', '--python', '3.12', '--upgrade'])
|
||||
assert.deepEqual(uvInstallArgs({ version: 'v0.8.0' }), ['tool', 'install', 'slopo==0.8.0', '--python', '3.12'])
|
||||
})
|
||||
|
||||
test('shellQuote survives a single quote in a path', () => {
|
||||
assert.equal(shellQuote("/tmp/it's here"), `'/tmp/it'\\''s here'`)
|
||||
assert.equal(commandLine(['/bin/x', 'a b']), `'/bin/x' 'a b'`)
|
||||
})
|
||||
|
||||
test('slopoArgs puts the global --config before the subcommand', () => {
|
||||
// typer only accepts a callback option ahead of the command; verified against slopo 0.8.0.
|
||||
for (const action of ['review', 'analyze', 'index', 'show-config']) {
|
||||
const args = slopoArgs('/c.yaml', action)
|
||||
assert.equal(args[0], '--config')
|
||||
assert.equal(args[1], '/c.yaml')
|
||||
assert.ok(args.findIndex((a) => a.startsWith('agent-') || a === 'index' || a === 'show-config') > 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('--config-version is a subcommand option, so it must FOLLOW the subcommand', () => {
|
||||
// Found the hard way: slopo exits 2 (a typer usage error) when --config-version
|
||||
// precedes the command, because it is declared on the command, not the callback.
|
||||
for (const action of ['review', 'analyze', 'analyze-one']) {
|
||||
const args = slopoArgs('/c.yaml', action)
|
||||
const commandAt = args.findIndex((a) => a.startsWith('agent-'))
|
||||
const versionAt = args.indexOf('--config-version')
|
||||
assert.ok(commandAt > 0, action)
|
||||
assert.ok(versionAt > commandAt, `${action}: --config-version must follow the subcommand`)
|
||||
}
|
||||
})
|
||||
|
||||
test('slopoArgs passes the config-version alarm on agent commands only', () => {
|
||||
assert.ok(slopoArgs('/c.yaml', 'review').includes('--config-version'))
|
||||
// index/embed are not agent commands and take no version flag.
|
||||
assert.ok(!slopoArgs('/c.yaml', 'index').includes('--config-version'))
|
||||
})
|
||||
|
||||
test('slopoArgs maps arguments, and omits them when empty', () => {
|
||||
assert.deepEqual(slopoArgs('/c.yaml', 'review', { base: 'main' }).slice(-2), ['--base', 'main'])
|
||||
assert.ok(!slopoArgs('/c.yaml', 'review', { base: '' }).includes('--base'))
|
||||
assert.deepEqual(slopoArgs('/c.yaml', 'analyze-one', { cluster: 'abc' }).slice(-3), ['--single', '--cluster', 'abc'])
|
||||
assert.ok(!slopoArgs('/c.yaml', 'analyze-one').includes('--cluster'))
|
||||
})
|
||||
|
||||
test('slopoArgs refuses an unknown action rather than running something else', () => {
|
||||
assert.throws(() => slopoArgs('/c.yaml', 'delete-everything'), /unknown slopo action/)
|
||||
})
|
||||
|
||||
test('explainFailure turns each observed slopo message into an action', () => {
|
||||
const cases = [
|
||||
['Error: No data in database. The tool wasn\'t initialized.', 'DATABASE_MISSING'],
|
||||
['Error: Configuration mismatch.', 'CONFIGURATION_MISMATCH'],
|
||||
['Error: Too many code units to embed. The tool probably wasn\'t fully initialized.', 'BACKLOG_TOO_LARGE'],
|
||||
['Error: Configuration file not found. Make sure that the agent CLI is run from the correct directory.', 'CONFIG_MISSING'],
|
||||
['Error: Loading configuration failed.', 'CONFIG_INVALID'],
|
||||
['Error: Index is out of date. Run `index` and `embed` first.', 'INDEX_STALE'],
|
||||
['Error: Git command failed.', 'GIT_FAILED'],
|
||||
['Error: Embedding failed.', 'EMBEDDING_FAILED'],
|
||||
['Error: something nobody has seen before', 'SLOPO_FAILED'],
|
||||
]
|
||||
for (const [stderr, code] of cases) {
|
||||
const explained = explainFailure({ stderr, stdout: '' })
|
||||
assert.equal(explained.code, code, stderr)
|
||||
assert.ok(explained.message.length > 0)
|
||||
assert.ok(explained.remedy.length > 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('explainFailure reads the real detail out of the log, not just stderr', () => {
|
||||
// This is the genuine shape: stderr is deliberately terse, the log is not.
|
||||
const explained = explainFailure({
|
||||
stderr: 'Error: Embedding failed.',
|
||||
stdout: '',
|
||||
logDetail: 'Error: litellm.UnsupportedParamsError: Setting dimensions is not supported',
|
||||
})
|
||||
assert.equal(explained.code, 'EMBEDDING_FAILED')
|
||||
})
|
||||
|
||||
test('lastLogError returns the most recent error line', () => {
|
||||
const log = [
|
||||
'==== 2026-01-01 00:00:00 agent-review ====',
|
||||
'Error: first problem',
|
||||
'> index',
|
||||
'Indexed 3 code units',
|
||||
'Error: litellm.UnsupportedParamsError: Setting dimensions is not supported',
|
||||
].join('\n')
|
||||
assert.match(lastLogError(log), /UnsupportedParamsError/)
|
||||
assert.equal(lastLogError('no errors here'), '')
|
||||
})
|
||||
|
||||
test('workspaceKey is stable, name-bearing and collision-resistant', () => {
|
||||
assert.equal(workspaceKey('/a/b/app'), workspaceKey('/a/b/app'))
|
||||
assert.notEqual(workspaceKey('/a/b/app'), workspaceKey('/x/y/app'))
|
||||
assert.match(workspaceKey('/a/b/app'), /^app-[0-9a-f]{8}$/)
|
||||
})
|
||||
|
||||
test('resolveDshHome prefers $DSH_HOME and rejects a blank one', () => {
|
||||
assert.equal(resolveDshHome({ DSH_HOME: '/persist/.dsh' }), '/persist/.dsh')
|
||||
assert.match(resolveDshHome({ DSH_HOME: ' ' }), /\.dsh$/)
|
||||
})
|
||||
|
||||
test('workspace state lives under the harness home, not next to the code', () => {
|
||||
assert.match(workspaceStateDir('/dsh', '/repo'), /^\/dsh\/dsh-slopo\/workspaces\/repo-[0-9a-f]{8}$/)
|
||||
})
|
||||
|
||||
test('the generated config is accepted by the real slopo', { skip: !existsSync(managedSlopoPath('/root/.dsh')) && !existsSync('/root/.local/bin/slopo') }, () => {
|
||||
const binary = findSlopo('/root/.dsh')
|
||||
assert.ok(binary, 'expected a slopo binary for this test')
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-slopo-conf-'))
|
||||
const configPath = join(dir, 'slopo.conf.yaml')
|
||||
writeFileSync(
|
||||
configPath,
|
||||
renderSlopoConfig({
|
||||
source_dir: dir,
|
||||
db_file: join(dir, 'slopo.db'),
|
||||
ignore_file: join(dir, 'slopo.ignore.txt'),
|
||||
agent_log_file: join(dir, 'slopo.agent.log'),
|
||||
embedding_model: 'ollama/example:latest',
|
||||
embedding_dimensions: 768,
|
||||
embedding_params: { api_base: 'http://127.0.0.1:11434' },
|
||||
source_dir_exclude: ['**/test/**'],
|
||||
}),
|
||||
)
|
||||
const out = execFileSync(binary, ['--config', configPath, 'show-config'], { encoding: 'utf8' })
|
||||
assert.match(out, /embedding_model: ollama\/example:latest/)
|
||||
assert.match(out, /embedding_dimensions: 768/)
|
||||
assert.match(out, /api_base: http:\/\/127\.0\.0\.1:11434/)
|
||||
})
|
||||
Reference in new issue
Block a user