From 3373e0b4f6a7ae9072ae50c3a072886b79cd40f1 Mon Sep 17 00:00:00 2001 From: dsh-slopo agent Date: Tue, 22 Sep 2026 06:11:17 +0100 Subject: [PATCH] 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. --- package.json | 2 +- test/config.test.js | 107 +++++++++++++++ test/plugin.test.js | 325 ++++++++++++++++++++++++++++++++++++++++++++ test/slopo.test.js | 177 ++++++++++++++++++++++++ 4 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 test/config.test.js create mode 100644 test/plugin.test.js create mode 100644 test/slopo.test.js diff --git a/package.json b/package.json index a2d9885..00d61dc 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "node": ">=20" }, "scripts": { - "test": "node --test test/" + "test": "node --test test/*.test.js" }, "dsh": { "bundle": { diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..cd58178 --- /dev/null +++ b/test/config.test.js @@ -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/) +}) diff --git a/test/plugin.test.js b/test/plugin.test.js new file mode 100644 index 0000000..6e3ae73 --- /dev/null +++ b/test/plugin.test.js @@ -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, /\* /) + }, +) diff --git a/test/slopo.test.js b/test/slopo.test.js new file mode 100644 index 0000000..468dac5 --- /dev/null +++ b/test/slopo.test.js @@ -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/) +})