phase5-verify.js 7.11 KB
#!/usr/bin/env node
/**
 * scripts/phase5-verify.js
 *
 * P5 Vue Dashboard 验收 (后端 + 静态产物):
 *   ① Vue 构建产物 (DevLoopDashboard + DevLoopOps + 8 子组件) 存在
 *   ② GET /:pid/state 返带 status + units 字段
 *   ③ GET /:pid/timeline 返事件列表
 *   ④ GET /:pid/changelog 返条目列表
 *   ⑤ GET /:pid/units/:unitId 返 unit + runs + coder_log_tail
 *   ⑥ GET /:pid/bugs 返 bug 列表
 *   ⑦ SSE /:pid/stream 一秒内收到 ≥1 心跳/事件
 *
 * 注: 这是后端 + 产物层面的验证;真正点击鼠标 UI 需要 vite dev 跑起来手测。
 */

process.env.LEMONBOSS_CLAUDE_MODE = 'mock';

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

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 lc = require('../server/src/core/devloop/loop-controller');
const devloopRouter = require('../server/routes/devloop');

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

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() {
  await step('① Vue build 产物存在 (Dashboard + Ops + components)', () => {
    const required = [
      'index.html', 'assets',
    ];
    for (const f of required) {
      if (!fs.existsSync(path.join(WEB_DIST, f))) throw new Error(`web/dist/${f} missing — run npm run web:build`);
    }
    const assets = fs.readdirSync(path.join(WEB_DIST, 'assets'));
    if (!assets.some(n => /DevLoopDashboard.*\.js$/.test(n))) throw new Error('DevLoopDashboard chunk not built');
    if (!assets.some(n => /DevLoopOps.*\.js$/.test(n))) throw new Error('DevLoopOps chunk not built');
    return { detail: `DevLoopDashboard + DevLoopOps chunks present in web/dist/assets` };
  });

  const { srv, base } = await startApp();
  const cid = 'c-phase5-' + Date.now().toString(36);
  let pid;

  try {
    await step('② 准备项目: POST /projects → state', async () => {
      const r = await fetch(`${base}/api/devloop/projects`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ company_id: cid, requirement: 'TODO', mockFixtureDir: HAPPY_DIR }),
      });
      pid = (await r.json()).pid;
      const s = await (await fetch(`${base}/api/devloop/projects/${pid}/state`)).json();
      if (!s.status) throw new Error('state.status missing');
      if (!Array.isArray(s.units)) throw new Error('state.units missing');
      return { detail: `pid=${pid} status=${s.status} units=${s.units.length}` };
    });

    await step('③ GET /:pid/timeline returns events', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline?limit=50`);
      const j = await r.json();
      if (!Array.isArray(j.events)) throw new Error('events not array');
      if (j.events.length === 0) throw new Error('events empty');
      return { detail: `${j.events.length} events` };
    });

    await step('④ GET /:pid/changelog returns entries', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`);
      const j = await r.json();
      if (!Array.isArray(j.entries)) throw new Error('entries not array');
      return { detail: `${j.entries.length} entries` };
    });

    await step('⑤ GET /:pid/units/todo-be returns unit + runs', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`);
      if (r.status !== 200) throw new Error(`status ${r.status}`);
      const j = await r.json();
      if (!j.unit) throw new Error('unit missing');
      if (!Array.isArray(j.runs)) throw new Error('runs not array');
      return { detail: `unit.status=${j.unit?.status} · ${j.runs.length} runs` };
    });

    await step('⑥ GET /:pid/bugs returns bug list', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}/bugs`);
      const j = await r.json();
      if (!Array.isArray(j.bugs)) throw new Error('bugs not array');
      return { detail: `${j.bugs.length} bugs` };
    });

    await step('⑦ SSE /:pid/stream delivers ≥1 event in 1s after emit', async () => {
      // 用 http.get 拿原生 stream
      const url = new URL(`${base}/api/devloop/projects/${pid}/stream`);
      const events = await new Promise((resolve) => {
        const out = [];
        const req = http.get({
          hostname: url.hostname, port: url.port, path: url.pathname,
          headers: { 'Accept': 'text/event-stream' },
        }, (res) => {
          let buf = '';
          res.on('data', (chunk) => {
            buf += chunk.toString();
            const matches = buf.matchAll(/^event: (.+)$/gm);
            for (const m of matches) out.push(m[1]);
            buf = buf.slice(buf.lastIndexOf('\n\n') + 2);
          });
          res.on('error', () => resolve(out));
        });
        // Trigger an event so we have something to receive
        setTimeout(async () => {
          try {
            await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
              method: 'POST', headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ text: 'feedback for sse test', mockFixtureDir: HAPPY_DIR }),
            });
          } catch { /* ignore */ }
        }, 100);
        setTimeout(() => { try { req.destroy(); } catch {} resolve(out); }, 1500);
      });
      if (events.length === 0) throw new Error('no SSE events received');
      return { detail: `${events.length} events: ${events.slice(0, 4).join(', ')}` };
    });

  } 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 5 — Vue Dashboard Acceptance ──────');
  for (const r of results) {
    const tag = r.ok ? '✓' : '✗';
    console.log(`│ ${tag} ${r.name.padEnd(60)} ${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[phase5-verify] fatal:', e);
  for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
  process.exit(1);
});