'use strict'; /** Gitea Workflow 核心领域模块单元测试,可直接执行:node scripts/gitea-workflow-core-unit.js */ const assert = require('assert'); const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); const domain = require('../lib/gitea-workflow-domain'); const { createGiteaWorkflowStore } = require('../lib/gitea-workflow-store'); const { createGiteaWorkflowQueue } = require('../lib/gitea-workflow-queue'); const { createGiteaWorkflowService, buildGiteaMcpConfig } = require('../lib/gitea-workflow-service'); function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function sign(body, secret) { return crypto.createHmac('sha256', secret).update(body).digest('hex'); } function tempState() { return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-core-')), 'state.json'); } function payload(body, number = 7, login = 'alice') { return { action: 'created', repository: { owner: { login: 'acme' }, name: 'demo', full_name: 'acme/demo', clone_url: 'https://gitea.example/acme/demo.git', default_branch: 'main', }, issue: { number, title: '测试 Issue' }, comment: { id: `${number}-comment`, body, user: { login } }, sender: { login }, }; } async function run() { const statePath = tempState(); const secret = 'unit-secret'; const results = []; const check = (name, fn) => Promise.resolve().then(fn).then(() => results.push(`PASS ${name}`)); await check('配置、标识与敏感字段脱敏', () => { const config = domain.normalizeConfig({ instanceId: 'default', gitea: { token: 'top-secret', webhookSecret: secret } }); assert.equal(config.maxConcurrency, undefined); assert.equal(domain.sessionKeyFor({ owner: 'acme', repo: 'demo', kind: 'issue', number: 7 }), 'default:acme/demo:issue:7'); assert.equal(domain.resourceKeyFor({ owner: 'acme', repo: 'demo', kind: 'issue', number: 7 }), 'default:acme/demo:issue:7'); const publicConfig = domain.publicConfig(config); assert.notEqual(publicConfig.gitea.token, 'top-secret'); assert.match(publicConfig.gitea.token, /^sha256:/); }); await check('状态机阻止非法迁移并允许正常迁移', () => { const task = domain.createTaskRecord({ taskId: 't-state', state: 'received' }); const queued = domain.transitionTask(task, 'queued'); assert.equal(queued.state, 'queued'); assert.throws(() => domain.transitionTask(queued, 'succeeded'), /不可从/); assert.equal(domain.transitionTask(queued, 'preparing').state, 'preparing'); }); await check('Webhook 验签、mention/Bot 过滤、delivery 去重与任务入队', async () => { const service = createGiteaWorkflowService({ statePath, config: { gitea: { webhookSecret: secret, host: 'https://gitea.example', token: 'bot-token' } }, runner: async () => ({ state: 'waiting_user' }), }); const body = JSON.stringify(payload('@ccweb-bot 请检查这个问题')); const headers = { 'x-gitea-signature': sign(body, secret), 'x-gitea-delivery': 'delivery-1', 'x-gitea-event': 'issue_comment' }; const accepted = await service.ingestWebhook({ rawBody: body, headers }); assert.equal(accepted.statusCode, 202); assert.ok(accepted.task.taskId); const duplicate = await service.ingestWebhook({ rawBody: body, headers }); assert.equal(duplicate.payload.duplicate, true); const botBody = JSON.stringify(payload('@ccweb-bot 自己回帖', 8, 'ccweb-bot')); const ignored = await service.ingestWebhook({ rawBody: botBody, headers: { ...headers, 'x-gitea-delivery': 'delivery-bot', 'x-gitea-signature': sign(botBody, secret) } }); assert.equal(ignored.payload.ignored, true); const invalid = await service.ingestWebhook({ rawBody: body, headers: { ...headers, 'x-gitea-delivery': 'delivery-invalid', 'x-gitea-signature': '00' } }); assert.equal(invalid.statusCode, 401); const internalService = createGiteaWorkflowService({ statePath: tempState(), config: { gitea: { host: 'https://gitea.example', token: 'bot-token' } }, runner: async () => ({ state: 'waiting_user' }), }); const internalBody = JSON.stringify(payload('@ccweb-bot 内部回调无需额外 Secret')); const internal = await internalService.ingestWebhook({ rawBody: internalBody, headers: { 'x-gitea-delivery': 'delivery-internal', 'x-gitea-event': 'issue_comment' }, }); assert.equal(internal.statusCode, 202); await wait(20); assert.equal(service.getTask(accepted.task.taskId).state, 'waiting_user'); assert.equal(service.listTasks().length, 1); }); await check('JSON 持久化与重启恢复 running/queued', () => { const store = createGiteaWorkflowStore({ filePath: statePath }); store.createTask({ taskId: 'restart-running', state: 'running', repoKey: 'default:acme/demo' }); store.createTask({ taskId: 'restart-queued', state: 'queued', repoKey: 'default:acme/demo2' }); const restored = createGiteaWorkflowStore({ filePath: statePath }); const recovered = restored.recover({ maxRestartRetries: 1 }); assert.equal(recovered.find((item) => item.taskId === 'restart-running').state, 'retry_wait'); assert.equal(recovered.find((item) => item.taskId === 'restart-running').errorCode, 'interrupted_by_restart'); assert.equal(restored.getTask('restart-queued').state, 'queued'); assert.throws(() => restored.transitionTask('restart-queued', 'preparing', { expectedVersion: 999 }), /版本已变化/); }); await check('同仓库串行、跨仓库并行', async () => { const store = createGiteaWorkflowStore({ filePath: tempState() }); const queue = createGiteaWorkflowQueue({ store, autoRecover: false }); const started = []; const released = new Set(); const runner = async (task) => { started.push(task.taskId); while (!released.has(task.taskId)) await wait(5); return { state: 'succeeded' }; }; const tasks = [ store.createTask({ taskId: 'repo-a-1', state: 'received', repoKey: 'repo-a' }), store.createTask({ taskId: 'repo-a-2', state: 'received', repoKey: 'repo-a' }), store.createTask({ taskId: 'repo-b-1', state: 'received', repoKey: 'repo-b' }), ]; const waits = tasks.map((task) => queue.enqueue(task.taskId, runner)); await wait(30); assert.equal(started.includes('repo-a-1'), true); assert.equal(started.includes('repo-b-1'), true); assert.equal(started.includes('repo-a-2'), false); released.add('repo-a-1'); await wait(30); assert.equal(started.includes('repo-a-2'), true); released.add('repo-a-2'); released.add('repo-b-1'); await Promise.all(waits); }); await check('线程级 gitea-mcp 配置不把 token 放入进程级配置', () => { const mcp = buildGiteaMcpConfig({ gitea: { host: 'https://gitea.example', token: 'abc' } }); assert.equal(mcp.type, 'stdio'); assert.deepEqual(mcp.args.slice(0, 2), ['-t', 'stdio']); assert.equal(mcp.env.GITEA_TOKEN, 'abc'); assert.equal(mcp.command, 'gitea-mcp'); const service = createGiteaWorkflowService({ config: { gitea: { host: 'https://gitea.example', token: 'abc' }, codex: { model: 'configured-model', reasoningEffort: 'medium' } }, autoRecover: false }); const thread = service.buildThreadConfig({ cwd: '/tmp/workspace' }); assert.equal(thread.collaborationMode.settings.model, 'configured-model'); assert.equal(thread.collaborationMode.settings.reasoning_effort, 'medium'); assert.equal(thread.model, undefined); assert.equal(thread.effort, undefined); }); await check('已规范化 Webhook 任务可通过 server 挂接契约入队', async () => { const service = createGiteaWorkflowService({ statePath: tempState(), autoRecover: false }); service.setRunner(async () => ({ state: 'waiting_user' })); const event = { instanceId: 'default', deliveryId: 'normalized-1', eventName: 'issue_comment', repository: { owner: 'acme', name: 'normalized', fullName: 'acme/normalized', cloneUrl: 'https://gitea.example/acme/normalized.git' }, resource: { kind: 'issue', number: 3, key: 'issue:3' }, comment: { id: 'comment-3', body: '@ccweb-bot 继续', author: { login: 'alice' } }, mention: { instruction: '继续' }, }; const accepted = service.enqueueNormalizedTask({ taskId: 'normalized-task', deliveryKey: 'default:normalized-1', deliveryId: 'normalized-1', event }); assert.equal(accepted.ok, true); assert.equal(accepted.duplicate, false); const duplicate = service.enqueueNormalizedTask({ taskId: 'normalized-task', deliveryKey: 'default:normalized-1', deliveryId: 'normalized-1', event }); assert.equal(duplicate.duplicate, true); await wait(30); assert.equal(service.getTask('normalized-task').state, 'waiting_user'); assert.ok(service.store.getTask('normalized-task').metadata.event.repository); const disabled = service.disableRepository({ repoKey: 'default:acme/normalized', actor: 'alice', reason: '维护' }); assert.equal(disabled.ok, true); const rejected = service.enqueueNormalizedTask({ taskId: 'normalized-task-2', deliveryKey: 'default:normalized-2', deliveryId: 'normalized-2', event: { ...event, deliveryId: 'normalized-2' } }); assert.equal(rejected.ok, false); assert.equal(rejected.code, 'repository_disabled'); }); console.log(results.join('\n')); console.log(`全部通过:${results.length} 项`); } run().catch((error) => { console.error(`FAIL ${error.stack || error}`); process.exitCode = 1; });