177 lines
9.4 KiB
JavaScript
177 lines
9.4 KiB
JavaScript
'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;
|
||
});
|