Files
evomap__evolver/test/adapters.opencode.test.js
evolver-publish fec8708d28 Release v1.90.0
2026-07-03 16:15:06 +08:00

446 lines
18 KiB
JavaScript

const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { canCreateSymlinks } = require('./helpers/symlink');
// Symlink-rejection tests need to plant a real symlink before exercising
// the code under test; on Windows non-admin that fails with EPERM in
// setup. Skip those tests when symlink creation isn't available.
const symlinkIt = canCreateSymlinks() ? it : it.skip;
const hookAdapter = require('../src/adapters/hookAdapter');
const opencodeAdapter = require('../src/adapters/opencode');
function makeTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-opencode-test-'));
}
function cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
function withClearedHostEnv(fn) {
const keys = [
'CLAUDECODE',
'CLAUDE_CODE_ENTRYPOINT',
'CLAUDE_PROJECT_DIR',
'CURSOR_TRACE_ID',
'CURSOR_SESSION_ID',
'CURSOR_PROJECT_DIR',
'CURSOR_AGENT',
'CODEX_THREAD_ID',
'CODEX_SHELL',
'CODEX_CI',
'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
'TERM_PROGRAM',
];
const oldEnv = {};
for (const key of keys) {
oldEnv[key] = process.env[key];
delete process.env[key];
}
try {
return fn();
} finally {
for (const key of keys) {
if (oldEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = oldEnv[key];
}
}
}
}
describe('opencode: registration in hookAdapter', () => {
it('PLATFORMS contains opencode entry', () => {
assert.ok(hookAdapter.PLATFORMS.opencode, 'PLATFORMS.opencode must be defined');
assert.equal(hookAdapter.PLATFORMS.opencode.configDir, '.opencode');
assert.equal(hookAdapter.PLATFORMS.opencode.detector, '.opencode');
assert.equal(hookAdapter.PLATFORMS.opencode.name, 'opencode');
});
it('loadAdapter("opencode") returns opencode module', () => {
const mod = hookAdapter.loadAdapter('opencode');
assert.ok(mod);
assert.equal(typeof mod.install, 'function');
assert.equal(typeof mod.uninstall, 'function');
});
it('detectPlatform finds opencode from .opencode directory', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
withClearedHostEnv(() => {
assert.equal(hookAdapter.detectPlatform(tmp), 'opencode');
});
} finally { cleanup(tmp); }
});
});
describe('opencode adapter: buildPluginSource', () => {
it('embeds the absolute hooks dir path', () => {
const src = opencodeAdapter.buildPluginSource('/some/abs/.opencode/hooks');
assert.match(src, /\/some\/abs\/\.opencode\/hooks/);
});
it('includes the _evolver_managed marker on the first line', () => {
const src = opencodeAdapter.buildPluginSource('/x');
const firstLine = src.split('\n')[0];
assert.match(firstLine, /_evolver_managed: true/);
});
it('wires session.created -> evolver-session-start.js', () => {
const src = opencodeAdapter.buildPluginSource('/x');
assert.match(src, /session\.created[\s\S]*evolver-session-start\.js/);
});
it('wires session.idle -> evolver-session-end.js', () => {
const src = opencodeAdapter.buildPluginSource('/x');
assert.match(src, /session\.idle[\s\S]*evolver-session-end\.js/);
});
it('wires tool.execute.after -> evolver-signal-detect.js with write/edit filter', () => {
const src = opencodeAdapter.buildPluginSource('/x');
assert.match(src, /'tool\.execute\.after'/);
assert.match(src, /evolver-signal-detect\.js/);
assert.match(src, /input\.tool !== 'write'/);
assert.match(src, /input\.tool !== 'edit'/);
});
it('exports both named Evolver and default for opencode loader compat', () => {
const src = opencodeAdapter.buildPluginSource('/x');
assert.match(src, /module\.exports\s*=\s*\{\s*Evolver\s*\}/);
assert.match(src, /module\.exports\.default\s*=\s*Evolver/);
});
it('produces source that parses as valid JavaScript', () => {
const src = opencodeAdapter.buildPluginSource('/x');
// Use Function constructor as a syntax-only check (does not execute the
// module body since the wrapper just receives the string).
assert.doesNotThrow(() => new Function(src));
});
});
describe('opencode adapter: install', () => {
it('writes plugin file, copies hook scripts, injects AGENTS.md', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
const result = opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
assert.equal(result.ok, true);
assert.equal(result.platform, 'opencode');
const pluginPath = path.join(tmp, '.opencode', 'plugins', 'evolver.js');
assert.ok(fs.existsSync(pluginPath), 'plugin file missing');
const pluginSrc = fs.readFileSync(pluginPath, 'utf8');
assert.match(pluginSrc, /_evolver_managed: true/);
const hooksDir = path.join(tmp, '.opencode', 'hooks');
for (const script of ['evolver-session-start.js', 'evolver-signal-detect.js', 'evolver-session-end.js']) {
assert.ok(fs.existsSync(path.join(hooksDir, script)), `script missing: ${script}`);
}
// The plugin must point at this install's hooks dir absolutely.
// On Windows, JSON.stringify escapes backslashes to \\, so compare against
// the escaped form that actually appears in the generated plugin source.
const hooksDirInSrc = JSON.stringify(hooksDir).slice(1, -1);
assert.ok(pluginSrc.includes(hooksDirInSrc), 'plugin should reference absolute hooks dir');
const agentsMd = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8');
assert.ok(agentsMd.includes('Evolution Memory'));
assert.ok(agentsMd.includes('evolver-evolution-memory'));
assert.ok(agentsMd.includes('Do not narrate routine Evolver checks'));
assert.ok(!agentsMd.includes('For substantive tasks'));
assert.ok(!agentsMd.includes('gep_recall'));
assert.ok(!agentsMd.includes('gep_record_outcome'));
} finally { cleanup(tmp); }
});
it('skips when already installed without force', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
const result = opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: false });
assert.equal(result.ok, true);
assert.equal(result.skipped, true);
} finally { cleanup(tmp); }
});
it('overwrites with --force', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
const result = opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
assert.equal(result.ok, true);
assert.notEqual(result.skipped, true);
} finally { cleanup(tmp); }
});
});
describe('opencode adapter: uninstall', () => {
it('removes plugin file, hook scripts, and AGENTS.md section', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
const result = opencodeAdapter.uninstall({ configRoot: tmp });
assert.equal(result.ok, true);
assert.equal(result.removed, true);
const pluginPath = path.join(tmp, '.opencode', 'plugins', 'evolver.js');
assert.ok(!fs.existsSync(pluginPath), 'plugin file not removed');
const hooksDir = path.join(tmp, '.opencode', 'hooks');
for (const script of ['evolver-session-start.js', 'evolver-signal-detect.js', 'evolver-session-end.js']) {
assert.ok(!fs.existsSync(path.join(hooksDir, script)), `script not removed: ${script}`);
}
const agentsMd = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8');
assert.ok(!agentsMd.includes('evolver-evolution-memory'));
} finally { cleanup(tmp); }
});
it('does not remove user-authored plugin without _evolver_managed marker', () => {
const tmp = makeTmpDir();
try {
const pluginsDir = path.join(tmp, '.opencode', 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true });
const userPluginPath = path.join(pluginsDir, 'evolver.js');
fs.writeFileSync(userPluginPath, '// user-authored, no managed marker\nmodule.exports = {};\n');
// install with force=false should skip because file exists, but also
// since marker is absent, the file is treated as user-owned. We force
// install to overwrite, then uninstall. Uninstall must NOT touch a
// non-managed file: re-create as user-owned and check.
opencodeAdapter.uninstall({ configRoot: tmp });
assert.ok(fs.existsSync(userPluginPath), 'user-authored plugin must be preserved');
const content = fs.readFileSync(userPluginPath, 'utf8');
assert.ok(content.includes('user-authored'));
} finally { cleanup(tmp); }
});
it('returns ok with removed=false when nothing to remove', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
const result = opencodeAdapter.uninstall({ configRoot: tmp });
assert.equal(result.ok, true);
assert.equal(result.removed, false);
} finally { cleanup(tmp); }
});
});
describe('opencode adapter: verify (issue #531)', () => {
it('reports ok=true for a fresh install with all checks green', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
const report = opencodeAdapter.verify({ configRoot: tmp });
assert.equal(report.ok, true, JSON.stringify(report, null, 2));
assert.equal(report.checks.length, 5);
for (const c of report.checks) {
assert.equal(c.ok, true, `check ${c.id} should pass: ${c.detail}`);
}
assert.match(report.note, /Plugin is installed and loadable/);
} finally { cleanup(tmp); }
});
it('reports ok=false when no install has happened', () => {
const tmp = makeTmpDir();
try {
const report = opencodeAdapter.verify({ configRoot: tmp });
assert.equal(report.ok, false);
const failed = report.checks.filter((c) => !c.ok).map((c) => c.id);
assert.ok(failed.includes('plugin_file_present'), 'should fail file-present check');
assert.ok(failed.includes('plugin_loadable'), 'should fail loadable check');
assert.match(report.note, /Re-run/);
} finally { cleanup(tmp); }
});
it('reports ok=false when plugin file is corrupted', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
// Corrupt the plugin file.
const pluginPath = path.join(tmp, '.opencode', 'plugins', 'evolver.js');
fs.writeFileSync(pluginPath, 'this is not valid javascript {{{', 'utf8');
const report = opencodeAdapter.verify({ configRoot: tmp });
assert.equal(report.ok, false);
const loadable = report.checks.find((c) => c.id === 'plugin_loadable');
assert.equal(loadable.ok, false);
} finally { cleanup(tmp); }
});
it('reports ok=false when one hook script is missing', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
fs.unlinkSync(path.join(tmp, '.opencode', 'hooks', 'evolver-signal-detect.js'));
const report = opencodeAdapter.verify({ configRoot: tmp });
assert.equal(report.ok, false);
const scripts = report.checks.find((c) => c.id === 'hook_scripts_present');
assert.equal(scripts.ok, false);
assert.match(scripts.detail, /evolver-signal-detect\.js/);
} finally { cleanup(tmp); }
});
it('reports ok=false when AGENTS.md section is missing', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
fs.unlinkSync(path.join(tmp, 'AGENTS.md'));
const report = opencodeAdapter.verify({ configRoot: tmp });
assert.equal(report.ok, false);
const md = report.checks.find((c) => c.id === 'agents_md_section');
assert.equal(md.ok, false);
} finally { cleanup(tmp); }
});
it('reports unmanaged when the plugin file lacks the marker', () => {
const tmp = makeTmpDir();
try {
const pluginsDir = path.join(tmp, '.opencode', 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true });
// user-authored plugin: parses, exports a function, but no managed marker
fs.writeFileSync(path.join(pluginsDir, 'evolver.js'),
'const Evolver = async () => ({});\nmodule.exports = { Evolver };\nmodule.exports.default = Evolver;\n',
'utf8',
);
const report = opencodeAdapter.verify({ configRoot: tmp });
const managed = report.checks.find((c) => c.id === 'plugin_managed_marker');
assert.equal(managed.ok, false);
assert.match(managed.detail, /not evolver-managed/);
} finally { cleanup(tmp); }
});
});
describe('opencode adapter: install post-message (issue #531)', () => {
it('install result includes plugin_path field for downstream consumers', () => {
const tmp = makeTmpDir();
try {
fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true });
const evolverRoot = path.resolve(__dirname, '..');
const result = opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
assert.equal(typeof result.plugin_path, 'string');
assert.match(result.plugin_path, /\.opencode[\\/]+plugins[\\/]+evolver\.js$/);
} finally { cleanup(tmp); }
});
it('printVerifyReport is exported as a function', () => {
assert.equal(typeof opencodeAdapter.printVerifyReport, 'function');
});
it('verify is exported as a function', () => {
assert.equal(typeof opencodeAdapter.verify, 'function');
});
});
describe('opencode adapter: isEvolverManagedPluginFile', () => {
it('detects files with _evolver_managed marker', () => {
const tmp = makeTmpDir();
try {
const p = path.join(tmp, 'evolver.js');
fs.writeFileSync(p, '// _evolver_managed: true\nmodule.exports = {};\n');
assert.equal(opencodeAdapter.isEvolverManagedPluginFile(p), true);
} finally { cleanup(tmp); }
});
it('rejects user-authored plugins without marker', () => {
const tmp = makeTmpDir();
try {
const p = path.join(tmp, 'evolver.js');
fs.writeFileSync(p, '// my custom plugin\nmodule.exports = {};\n');
assert.equal(opencodeAdapter.isEvolverManagedPluginFile(p), false);
} finally { cleanup(tmp); }
});
it('returns false for non-existent file', () => {
const tmp = makeTmpDir();
try {
assert.equal(
opencodeAdapter.isEvolverManagedPluginFile(path.join(tmp, 'nope.js')),
false
);
} finally { cleanup(tmp); }
});
});
// PR #94 round-5: nested-subdir symlink hole. opencode is the only adapter
// that owns *two* nested subdirs (`hooks/` for the shared scripts, `plugins/`
// for the auto-generated evolver.js). Both must be guarded.
describe('opencode adapter: rejects symlinked nested subdirs (PR #94 round-5)', () => {
symlinkIt('install refuses symlinked .opencode/hooks', () => {
const tmp = makeTmpDir();
try {
const realConfig = path.join(tmp, '.opencode');
fs.mkdirSync(realConfig, { recursive: true });
const target = path.join(tmp, 'redirect');
fs.mkdirSync(target, { recursive: true });
fs.symlinkSync(target, path.join(realConfig, 'hooks'), 'dir');
const evolverRoot = path.resolve(__dirname, '..');
assert.throws(
() => opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true }),
/symbolic link/i
);
} finally { cleanup(tmp); }
});
symlinkIt('install refuses symlinked .opencode/plugins', () => {
const tmp = makeTmpDir();
try {
const realConfig = path.join(tmp, '.opencode');
fs.mkdirSync(realConfig, { recursive: true });
const target = path.join(tmp, 'redirect');
fs.mkdirSync(target, { recursive: true });
fs.symlinkSync(target, path.join(realConfig, 'plugins'), 'dir');
const evolverRoot = path.resolve(__dirname, '..');
assert.throws(
() => opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true }),
/symbolic link/i
);
} finally { cleanup(tmp); }
});
symlinkIt('uninstall refuses symlinked nested dirs', () => {
const tmp = makeTmpDir();
try {
const realConfig = path.join(tmp, '.opencode');
fs.mkdirSync(realConfig, { recursive: true });
const target = path.join(tmp, 'redirect');
fs.mkdirSync(target, { recursive: true });
fs.symlinkSync(target, path.join(realConfig, 'hooks'), 'dir');
assert.throws(
() => opencodeAdapter.uninstall({ configRoot: tmp }),
/symbolic link/i
);
} finally { cleanup(tmp); }
});
});