/** * coder-tools 单测 */ const fs = require('fs'); const path = require('path'); const os = require('os'); const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-ct-')); process.env.HOME = TEST_HOME; const pathResolver = require('../../../services/devloop/path-resolver'); const docStore = require('../../../services/devloop/doc-store'); const tools = require('../../../src/tools/devloop/coder-tools'); function setupProject() { pathResolver._resetForTesting(); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ct-proj-')); const pid = 'p-codetest'; const reg = { [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, }; fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); fs.writeFileSync( path.join(TEST_HOME, '.devloop', 'linked-projects.json'), JSON.stringify(reg) ); pathResolver._resetForTesting(); return { pid, root }; } describe('coder-tools', () => { let env, ctx; beforeEach(() => { env = setupProject(); ctx = { pid: env.pid, agent: 'coder' }; }); afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); }); describe('contract_bridge', () => { function buildContract(exports) { // hashContract uses contract.exports || contract, so version field stays stable const c = { exports }; c.version = docStore.hashContract(c); return c; } it('returns ok when hash matches', async () => { const unitId = 'u-be-auth'; const unitDir = path.join(env.root, 'units', unitId); fs.mkdirSync(unitDir, { recursive: true }); const contract = buildContract({ http: [{ method: 'POST', path: '/api/login' }] }); fs.writeFileSync( path.join(unitDir, 'contract.json'), JSON.stringify(contract, null, 2) ); fs.writeFileSync( path.join(env.root, 'units.json'), JSON.stringify({ units: [{ id: unitId, kind: 'be', contract_hash: contract.version, depends_on: [] }], }) ); const r = await tools.contract_bridge.execute({ unit_id: unitId }, ctx); expect(r.ok).toBe(true); expect(r.hash).toBe(contract.version); }); it('flags hash mismatch (SDD-declared hash differs)', async () => { const unitId = 'u-be-mis'; const unitDir = path.join(env.root, 'units', unitId); fs.mkdirSync(unitDir, { recursive: true }); const contract = buildContract({ http: [{ method: 'GET', path: '/api/x' }] }); fs.writeFileSync( path.join(unitDir, 'contract.json'), JSON.stringify(contract) ); fs.writeFileSync( path.join(env.root, 'units.json'), JSON.stringify({ units: [{ id: unitId, kind: 'be', contract_hash: 'sha256:deadbeefdeadbeef', depends_on: [] }], }) ); const r = await tools.contract_bridge.execute({ unit_id: unitId }, ctx); expect(r.ok).toBe(false); expect(r.reason).toMatch(/does not match SDD/); }); it('returns ok=false when contract missing', async () => { const r = await tools.contract_bridge.execute({ unit_id: 'u-nope' }, ctx); expect(r.ok).toBe(false); expect(r.reason).toMatch(/contract\.json missing/); }); }); describe('append_coder_log', () => { it('writes attempt=1 entry without retry fields', async () => { const r = await tools.append_coder_log.execute({ unit_id: 'u-foo', attempt: 1, this_fix: 'init', rationale: 'first', }, ctx); expect(r.ok).toBe(true); const content = fs.readFileSync(r.path, 'utf8'); expect(content).toMatch(/attempt: 1/); expect(content).toMatch(/this_fix: init/); }); it('rejects retry without last_failure/last_cause', async () => { await expect(tools.append_coder_log.execute({ unit_id: 'u-foo', attempt: 2, this_fix: 'try again', rationale: 'hmm', }, ctx)).rejects.toThrow(/last_failure.*last_cause/); }); it('accepts retry with required fields', async () => { const r = await tools.append_coder_log.execute({ unit_id: 'u-foo', attempt: 2, last_failure: 'auth returns 500', last_cause: 'user undefined before bcrypt.compare', this_fix: 'add user null check', rationale: 'guard against missing user', }, ctx); expect(r.ok).toBe(true); expect(r.attempt).toBe(2); }); }); describe('sandbox_check', () => { it('returns ok when no out-of-sandbox writes', async () => { const unitId = 'u-x'; const unitDir = path.join(env.root, 'units', unitId, 'code'); fs.mkdirSync(unitDir, { recursive: true }); const since = Date.now() - 10_000; fs.writeFileSync(path.join(unitDir, 'a.js'), 'console.log(1);'); const r = await tools.sandbox_check.execute({ unit_id: unitId, since }, ctx); expect(r.ok).toBe(true); expect(r.violations).toEqual([]); }); it('flags writes outside sandbox', async () => { const unitId = 'u-y'; fs.mkdirSync(path.join(env.root, 'units', unitId, 'code'), { recursive: true }); const since = Date.now() - 1_000; // 写一个非沙箱目录文件 const badPath = path.join(env.root, 'rogue', 'evil.js'); fs.mkdirSync(path.dirname(badPath), { recursive: true }); fs.writeFileSync(badPath, 'oops'); const r = await tools.sandbox_check.execute({ unit_id: unitId, since }, ctx); expect(r.ok).toBe(false); expect(r.violations.length).toBeGreaterThan(0); }); }); });