e2e-gitops-validate.js 9.22 KB
#!/usr/bin/env node
/**
 * e2e-gitops-validate.js — GitOps 全流程端到端验证
 *
 * 在本地模拟完整的 GitOps 流程(无需远程服务器),验证:
 *   1. pre-task.sh  — Git 凭证注入 + 分支创建
 *   2. (模拟)       — Agent 在工作区修改文件
 *   3. post-task-success.sh — Git add + commit + push
 *   4. 验证          — bare repo 收到正确的 commit
 *
 * 用法:node scripts/e2e-gitops-validate.js
 */

const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');

let passed = 0;
let failed = 0;

function assert(condition, msg) {
  if (!condition) {
    console.error(`  \x1b[31m❌ FAIL:\x1b[0m ${msg}`);
    failed++;
    return false;
  }
  console.log(`  \x1b[32m✅ PASS:\x1b[0m ${msg}`);
  passed++;
  return true;
}

function step(n, msg) {
  console.log(`\n\x1b[36m[${n}]\x1b[0m ${msg}`);
}

function git(args, opts = {}) {
  return execSync(`git ${args}`, {
    encoding: 'utf-8',
    stdio: 'pipe',
    ...opts,
  }).trim();
}

function runHook(script, env, cwd) {
  const result = spawnSync('bash', [script], {
    cwd,
    env: { ...process.env, ...env },
    encoding: 'utf-8',
    stdio: 'pipe',
  });

  if (result.stdout) {
    result.stdout.split('\n').forEach(l => l && console.log(`    ${l}`));
  }
  if (result.stderr) {
    result.stderr.split('\n').forEach(l => l && console.log(`    \x1b[33m${l}\x1b[0m`));
  }

  return result;
}

// ─── 临时目录 ───

const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'gitops-e2e-'));
const BARE_REPO = path.join(TMP, 'origin.git');
const WORKSPACE = path.join(TMP, 'workspace');
const HOOKS_DIR = path.join(__dirname, '../agent-bridge/hooks');

// 检查 hooks 存在
if (!fs.existsSync(path.join(HOOKS_DIR, 'pre-task.sh'))) {
  console.error('❌ 找不到 agent-bridge/hooks/pre-task.sh');
  process.exit(1);
}

console.log(`
╔═══════════════════════════════════════╗
║  GitOps E2E 全流程验证               ║
╚═══════════════════════════════════════╝
  临时目录: ${TMP}
  Hooks:    ${HOOKS_DIR}
`);

try {
  // ═══════════════════════════════════
  step(1, '创建 bare repo + 初始 commit');
  // ═══════════════════════════════════

  execSync(`git init --bare ${BARE_REPO}`, { stdio: 'pipe' });

  // 创建临时 clone 做初始提交
  const initDir = path.join(TMP, 'init');
  git(`clone ${BARE_REPO} ${initDir}`);
  fs.writeFileSync(path.join(initDir, 'README.md'), '# Test Project\n');
  git('add -A', { cwd: initDir });
  git('config user.email "test@e2e.local"', { cwd: initDir });
  git('config user.name "E2E Test"', { cwd: initDir });
  git('commit -m "initial commit"', { cwd: initDir });
  git('push origin main', { cwd: initDir });
  fs.rmSync(initDir, { recursive: true });

  assert(true, 'bare repo 创建完成 + 初始 commit');

  // ═══════════════════════════════════
  step(2, '克隆工作区');
  // ═══════════════════════════════════

  git(`clone ${BARE_REPO} ${WORKSPACE}`);
  git('config user.email "agent@e2e.local"', { cwd: WORKSPACE });
  git('config user.name "E2E Agent"', { cwd: WORKSPACE });

  assert(fs.existsSync(path.join(WORKSPACE, '.git')), '工作区克隆成功');

  // ═══════════════════════════════════
  step(3, '运行 pre-task.sh');
  // ═══════════════════════════════════

  const preTaskEnv = {
    TASK_ID: 'e2e-001',
    AGENT_NAME: 'test-coder',
    GIT_REMOTE: 'origin',
    HOME: os.homedir(),
    PATH: process.env.PATH,
  };

  const preResult = runHook(path.join(HOOKS_DIR, 'pre-task.sh'), preTaskEnv, WORKSPACE);
  assert(preResult.status === 0, 'pre-task.sh 退出码 = 0');

  const branch = git('branch --show-current', { cwd: WORKSPACE });
  assert(branch === 'feat/task-e2e-001', `分支名正确: ${branch}`);

  // ═══════════════════════════════════
  step(4, '模拟 Agent 修改文件');
  // ═══════════════════════════════════

  const agentFile = path.join(WORKSPACE, 'solution.js');
  fs.writeFileSync(agentFile, `
// Auto-generated by test-coder agent
// Task: e2e-001
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}
module.exports = { fibonacci };
`);

  assert(fs.existsSync(agentFile), 'Agent 生成了 solution.js');

  // ═══════════════════════════════════
  step(5, '运行 post-task-success.sh');
  // ═══════════════════════════════════

  const postEnv = {
    TASK_ID: 'e2e-001',
    AGENT_NAME: 'test-coder',
    EXIT_CODE: '0',
    GIT_REMOTE: 'origin',
    HOME: os.homedir(),
    PATH: process.env.PATH,
  };

  const postResult = runHook(path.join(HOOKS_DIR, 'post-task-success.sh'), postEnv, WORKSPACE);
  assert(postResult.status === 0, 'post-task-success.sh 退出码 = 0');

  // ═══════════════════════════════════
  step(6, '验证 bare repo');
  // ═══════════════════════════════════

  // 检查分支是否推送成功
  const branches = git(`--git-dir=${BARE_REPO} branch`);
  assert(branches.includes('feat/task-e2e-001'), `bare repo 包含 feat/task-e2e-001 分支`);

  // 检查 commit 消息
  const log = git(`--git-dir=${BARE_REPO} log feat/task-e2e-001 --oneline -1`);
  assert(log.includes('e2e-001'), `commit 消息包含 task ID: ${log}`);
  assert(log.includes('test-coder'), `commit 消息包含 agent name: ${log}`);

  // 检查文件内容
  const content = git(`--git-dir=${BARE_REPO} show feat/task-e2e-001:solution.js`);
  assert(content.includes('fibonacci'), 'bare repo 中 solution.js 包含 fibonacci');

  // ═══════════════════════════════════
  step(7, '验证 GIT_TOKEN 凭证注入(dry run)');
  // ═══════════════════════════════════

  // 在干净目录中测试凭证注入(不写入真实 ~/.git-credentials)
  const credDir = path.join(TMP, 'cred-test');
  fs.mkdirSync(credDir);
  git('init', { cwd: credDir });
  git('config user.email "x@x.x"', { cwd: credDir });
  git('config user.name "x"', { cwd: credDir });
  git(`remote add origin ${BARE_REPO}`, { cwd: credDir });  // 添加 remote 使 fetch 不报错
  // 创建初始 commit(pre-task.sh 需要 HEAD 存在才能 checkout 分支)
  fs.writeFileSync(path.join(credDir, '.gitkeep'), '');
  git('add .', { cwd: credDir });
  git('commit -m "init"', { cwd: credDir });

  const credEnv = {
    TASK_ID: 'cred-test',
    AGENT_NAME: 'test',
    GIT_TOKEN: 'ghp_test123',
    GIT_HOST: 'github.example.com',
    HOME: credDir,  // 隔离——写到临时目录
    PATH: process.env.PATH,
  };

  const credResult = runHook(path.join(HOOKS_DIR, 'pre-task.sh'), credEnv, credDir);
  assert(credResult.status === 0, 'pre-task.sh(带 GIT_TOKEN)退出码 = 0');

  const credFile = path.join(credDir, '.git-credentials');
  if (fs.existsSync(credFile)) {
    const credContent = fs.readFileSync(credFile, 'utf-8');
    assert(credContent.includes('ghp_test123'), '.git-credentials 包含 token');
    assert(credContent.includes('github.example.com'), '.git-credentials 包含自定义 host');

    const stat = fs.statSync(credFile);
    const mode = '0' + (stat.mode & 0o777).toString(8);
    assert(mode === '0600', `.git-credentials 权限 = ${mode}`);
  } else {
    assert(false, '.git-credentials 文件存在');
  }

  // ═══════════════════════════════════
  step(8, '清理');
  // ═══════════════════════════════════

  fs.rmSync(TMP, { recursive: true, force: true });
  assert(true, '临时目录已清理');

} catch (e) {
  console.error(`\n💥 异常: ${e.message}`);
  console.error(e.stack);
  // 清理
  try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { }
}

// ═══════════════════════════════════
console.log(`
══════════════════════════════════
  总计: ${passed + failed}  通过: ${passed}  失败: ${failed}
══════════════════════════════════`);

if (failed > 0) {
  console.error('💥 有测试失败!');
  process.exit(1);
} else {
  console.log('🎉 GitOps E2E 全流程验证通过!');
  process.exit(0);
}