phase4-verify.js 8.23 KB
#!/usr/bin/env node
/**
 * scripts/phase4-verify.js
 *
 * P4 L3 Human Feedback 验收 (mock 模式 · 端到端 HTTP):
 *   ① POST /projects → status=running, PRD rev 1
 *   ② POST /run     → ships, status=shipped
 *   ③ POST /feedback → PRD rev bumped to 2, status shipped→running, changelog prd_rev=2
 *   ④ POST /run (re-run) → status=shipped again
 *   ⑤ POST /sign-off → status=shipped-final
 *   ⑥ POST /feedback (after sign-off) → 400 InvalidProjectStatusTransition
 *   ⑦ POST /sign-off (after sign-off) → 400
 *   ⑧ 第二个项目: POST /abandon → status=abandoned
 *   ⑨ 第二个项目 POST /abandon 再次 → 400
 */

process.env.LEMONBOSS_CLAUDE_MODE = 'mock';

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

const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
require('module').globalPaths.push(serverNodeModules);
const express = (() => {
  try { return require('express'); }
  catch {
    return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
  }
})();

const pathResolver = require('../server/services/devloop/path-resolver');
const sseBroker = require('../server/services/devloop/sse-broker');
const devloopRouter = require('../server/routes/devloop');

const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path');

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 startApp() {
  const app = express();
  app.use(express.json());
  app.use('/api/devloop', devloopRouter);
  return new Promise((resolve) => {
    const srv = app.listen(0, '127.0.0.1', () => {
      resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` });
    });
  });
}

async function main() {
  const { srv, base } = await startApp();
  const cid = 'c-phase4-' + Date.now().toString(36);
  let pid1, pid2;

  try {
    await step('① POST /projects → status=running', async () => {
      const r = await fetch(`${base}/api/devloop/projects`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ company_id: cid, requirement: 'TODO demo', mockFixtureDir: HAPPY_DIR }),
      });
      if (r.status !== 200) throw new Error('status ' + r.status);
      const j = await r.json();
      pid1 = j.pid;
      const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json();
      if (s.status !== 'running') throw new Error('want running, got ' + s.status);
      return { detail: `pid=${pid1} status=running prd_rev=${s.prd_rev}` };
    });

    await step('② POST /run → status=shipped', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }),
      });
      const j = await r.json();
      if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state);
      const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json();
      if (s.status !== 'shipped') throw new Error('status=' + s.status);
      return { detail: `final_state=shipped l2_rounds=${j.l2_rounds}` };
    });

    await step('③ POST /feedback → PRD rev 1→2, status shipped→running', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text: 'add toggle done', by: 'yibo', mockFixtureDir: HAPPY_DIR }),
      });
      if (r.status !== 200) throw new Error('status ' + r.status);
      const j = await r.json();
      if (j.prd_rev_after !== 2) throw new Error('prd_rev_after=' + j.prd_rev_after);
      if (j.status !== 'running') throw new Error('status=' + j.status);
      // changelog
      const cl = pathResolver.resolveChangelog(pid1);
      const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
      if (!lines.some(l => l.kind === 'prd_rev' && l.rev === 2)) throw new Error('no prd_rev=2 in changelog');
      return { detail: `rev 1→2 · status=running · changelog updated` };
    });

    await step('④ POST /run (re-run) → status=shipped', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }),
      });
      const j = await r.json();
      if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state);
      return { detail: 'shipped again' };
    });

    await step('⑤ POST /sign-off → status=shipped-final', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ by: 'yibo' }),
      });
      if (r.status !== 200) throw new Error('status ' + r.status);
      const j = await r.json();
      if (j.status !== 'shipped-final') throw new Error('status=' + j.status);
      return { detail: 'status=shipped-final' };
    });

    await step('⑥ POST /feedback (after sign-off) → 400', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text: 'late change' }),
      });
      if (r.status !== 400) throw new Error('want 400, got ' + r.status);
      const j = await r.json();
      if (j.from !== 'shipped-final') throw new Error('from=' + j.from);
      return { detail: '400 InvalidProjectStatusTransition (from=shipped-final)' };
    });

    await step('⑦ POST /sign-off (after sign-off) → 400', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
      });
      if (r.status !== 400) throw new Error('want 400, got ' + r.status);
      return { detail: '400 (terminal state)' };
    });

    await step('⑧ 第二项目: POST /abandon → status=abandoned', async () => {
      const cr = await fetch(`${base}/api/devloop/projects`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ company_id: cid, requirement: 'abandon test', mockFixtureDir: HAPPY_DIR }),
      });
      pid2 = (await cr.json()).pid;
      const ab = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ by: 'yibo', reason: 'mock test' }),
      });
      if (ab.status !== 200) throw new Error('abandon status ' + ab.status);
      const j = await ab.json();
      if (j.status !== 'abandoned') throw new Error('status=' + j.status);
      return { detail: `pid=${pid2} status=abandoned` };
    });

    await step('⑨ POST /abandon (after abandon) → 400', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
      });
      if (r.status !== 400) throw new Error('want 400, got ' + r.status);
      return { detail: '400 (terminal state)' };
    });

  } finally {
    srv.close();
    try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); }
    catch { /* ignore */ }
  }

  let pass = 0, fail = 0;
  console.log('\n┌─ DevLoop Phase 4 — L3 Human Feedback Acceptance ──');
  for (const r of results) {
    const tag = r.ok ? '✓' : '✗';
    console.log(`│ ${tag} ${r.name.padEnd(58)} ${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(e => {
  console.error('\n[phase4-verify] fatal:', e);
  for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
  process.exit(1);
});