coder-tools.test.js 5.4 KB
/**
 * 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);
    });
  });
});