phase0-test-call.js 6.64 KB
#!/usr/bin/env node
/**
 * scripts/phase0-test-call.js
 *
 * 真跑一遍 mock 模式下的 PM Agent 调用,并验证:
 *  [1] LoopController.createProject 能在 sandbox 模式下分配项目 + git init + 首次 commit
 *  [2] ClaudeCliProtocol(mock=true) 能 connect / sendPrompt / disconnect 无报错
 *  [3] fixture 的 chunks 全部通过 session_update 流出
 *  [4] session/result 事件 mock=true & stop_reason=end_turn
 *  [5] LoopController.commitAfterAgent 在工作树无改动时返回 null
 *  [6] 写一个虚拟产出文件 → commitAfterAgent 能产出新 sha
 *  [7] git log 至少 2 个 commit (init + agent 产出)
 *
 * 通过即 exit 0,任一失败 exit 1。
 *
 * 用法:  LEMONBOSS_CLAUDE_MODE=mock node scripts/phase0-test-call.js
 *        (脚本会自动设置环境变量,无需手动)
 */

process.env.LEMONBOSS_CLAUDE_MODE = 'mock';

const fs = require('fs');
const path = require('path');
const os = require('os');

const loopController = require('../server/src/core/devloop/loop-controller');
const pathResolver = require('../server/services/devloop/path-resolver');
const gitVault = require('../server/services/devloop/git-vault');
const { ClaudeCliProtocol } = require('../packages/acp-core/protocol/ClaudeCliProtocol');

const results = [];
function step(name, fn) {
  return (async () => {
    try {
      const r = await fn();
      results.push({ name, ok: true, detail: r?.detail || '' });
      return r;
    } catch (e) {
      results.push({ name, ok: false, reason: e.message });
      throw e;
    }
  })();
}

async function main() {
  let pid;

  // 用唯一 company_id 隔离这次跑(不污染 real data dir 的常规 c-test)
  const cid = 'c-phase0-' + Date.now().toString(36);

  try {
    const proj = await step('createProject (sandbox + git init)', async () => {
      const r = await loopController.createProject({
        company_id: cid,
        mode: 'sandbox',
        idea: 'Phase0 acceptance test: minimal devloop scaffolding',
      });
      if (!r.pid?.startsWith('p-')) throw new Error('bad pid: ' + r.pid);
      if (!fs.existsSync(path.join(r.root_path, '.git'))) {
        throw new Error('.git not initialized');
      }
      return { detail: `pid=${r.pid} sha=${r.first_commit?.sha?.slice(0, 7) || 'init'}` };
    });
    pid = (await pathResolver.listProjects()).find(p => p.company_id === cid)?.pid;
    if (!pid) {
      const all = pathResolver.listProjects();
      pid = all.find(p => p.company_id === cid)?.pid;
    }
    if (!pid) throw new Error('cannot resolve pid after createProject');

    const protocol = new ClaudeCliProtocol({ name: 'devloop-pm' }, { mock: true });

    await step('ClaudeCliProtocol(mock) connect', async () => {
      await protocol.connect(null, pathResolver.resolveProjectRoot(pid));
      if (!protocol.isConnected) throw new Error('not connected');
      return { detail: 'isConnected=true' };
    });

    const chunks = [];
    let resultEvent = null;
    protocol.on('session_update', (u) => {
      if (u.update?.sessionUpdate === 'agent_message_chunk') {
        chunks.push(u.update.content.text);
      }
    });
    protocol.on('acp_event', (ev) => {
      if (ev.method === 'session/result') resultEvent = ev.params;
    });

    await step('sendPrompt → chunks + result', async () => {
      await protocol.sendPrompt('please scaffold a minimal PRD for a TODO app');
      if (chunks.length === 0) throw new Error('no chunks received');
      if (!resultEvent) throw new Error('no session/result');
      if (resultEvent.mock !== true) throw new Error('result.mock !== true');
      if (resultEvent.stop_reason !== 'end_turn') throw new Error('bad stop_reason: ' + resultEvent.stop_reason);
      return { detail: `${chunks.length} chunks, result.length=${resultEvent.result.length}` };
    });

    protocol.disconnect();

    await step('commitAfterAgent (no changes → null)', async () => {
      const r = await loopController.commitAfterAgent(pid, 'devloop-pm', 'no-op test');
      if (r !== null) throw new Error('expected null, got: ' + JSON.stringify(r));
      return { detail: 'no commit when clean' };
    });

    await step('write virtual PRD file + commitAfterAgent', async () => {
      const root = pathResolver.resolveProjectRoot(pid);
      const prdDir = path.join(root, 'prd');
      fs.mkdirSync(prdDir, { recursive: true });
      fs.writeFileSync(path.join(prdDir, 'index.html'), '<!doctype html><html><body>mock</body></html>');
      const r = await loopController.commitAfterAgent(pid, 'devloop-pm', 'mock PRD scaffold');
      if (!r || !r.sha) throw new Error('expected commit, got: ' + JSON.stringify(r));
      return { detail: `sha=${r.sha.slice(0, 7)}` };
    });

    await step('git log shows ≥2 commits', async () => {
      const log = await gitVault.log(pid, { limit: 10 });
      const commits = Array.isArray(log) ? log : (log.commits || []);
      if (commits.length < 2) {
        throw new Error('expected ≥2 commits, got: ' + commits.length);
      }
      return { detail: `${commits.length} commits` };
    });

    await step('timeline.ndjson has project.created + git.commit events', async () => {
      const tl = pathResolver.resolveTimelineLog(pid);
      if (!fs.existsSync(tl)) throw new Error('timeline.ndjson missing');
      const lines = fs.readFileSync(tl, 'utf8').trim().split('\n').filter(Boolean);
      const kinds = lines.map(l => { try { return JSON.parse(l).kind; } catch { return ''; }});
      if (!kinds.includes('project.created')) throw new Error('no project.created in timeline');
      if (!kinds.includes('git.commit')) throw new Error('no git.commit in timeline');
      return { detail: `${lines.length} timeline events` };
    });

  } finally {
    // cleanup: 删 sandbox 项目目录(本次测试专用)
    if (pid) {
      try {
        const root = pathResolver.resolveProjectRoot(pid);
        const companyDir = path.dirname(path.dirname(root));
        if (companyDir.includes(cid)) {
          fs.rmSync(companyDir, { recursive: true, force: true });
        }
      } catch { /* ignore */ }
    }
  }

  let pass = 0, fail = 0;
  console.log('\n┌─ DevLoop Phase 0 — Mock End-to-End Test ────────────');
  for (const r of results) {
    const tag = r.ok ? '✓' : '✗';
    console.log(`│ ${tag} ${r.name.padEnd(50)} ${r.ok ? r.detail : '— ' + r.reason}`);
    r.ok ? pass++ : fail++;
  }
  console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`);

  process.exit(fail ? 1 : 0);
}

main().catch(err => {
  console.error('\n[phase0-test-call] fatal:', err);
  for (const r of results) {
    console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
  }
  process.exit(1);
});