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:
dsh-slopo agent committed 2026-09-22 06:11:17 +01:00
1 parent 31991143e4
commit 3373e0b4f6
4 files changed
+610 -1

No files matched your search

+177
View File
@@ -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/)
})