Files
cc-web/scripts/gitea-workflow-core-unit.js

177 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;
});