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