'use strict'; const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { Readable } = require('stream'); const { createGiteaWorkflowManagement, handleGiteaWorkflowManagementApi, } = require('../lib/gitea-workflow-management'); function request(method, body = null) { const stream = Readable.from(body == null ? [] : [Buffer.from(JSON.stringify(body))]); stream.method = method; return stream; } function response() { return { statusCode: 0, headersSent: false, headers: {}, writeHead(code, headers) { this.statusCode = code; this.headers = headers; }, end(value) { this.body = String(value || ''); this.headersSent = true; }, }; } function url(value) { return new URL(`http://localhost${value}`); } async function call(management, method, pathname, body, authenticated = true) { const res = response(); await handleGiteaWorkflowManagementApi(request(method, body), res, url(pathname), management, { authenticate: () => authenticated, }); return { status: res.statusCode, body: JSON.parse(res.body || '{}') }; } async function main() { const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'index.html'), 'utf8'); const frontend = fs.readFileSync(path.join(__dirname, '..', 'public', 'gitea-workflow.js'), 'utf8'); assert(html.includes('gitea-workflow-open') && html.includes('gitea-workflow-panel'), '页面入口未挂接'); assert(html.includes('gitea-workflow.js?v=__CC_WEB_GITEA_WORKFLOW_ASSET_VERSION__'), 'Gitea 管理脚本应使用独立缓存版本'); assert(html.includes('gitea-workflow.css?v=__CC_WEB_GITEA_WORKFLOW_ASSET_VERSION__'), 'Gitea 管理样式应使用独立缓存版本'); for (const marker of ['仓库与工作区', '任务队列与 Turn', '运行日志', '操作记录', '暂停全局', '中止 Turn']) { assert(frontend.includes(marker), `前端缺少 ${marker} 展示/控制`); } for (const marker of ['gitea-workflow-actor', 'gitea-workflow-reason', 'gitea-config-webhook-secret', 'gitea-config-concurrency', 'operatorReason', 'controlFields', '填写本次运维操作原因', '凭据仅写入 0600 运行目录,不回显;保存后重启 cc-web 才会应用到 Webhook/MCP。', '全局并发上限', 'Webhook Secret']) { assert(!frontend.includes(marker), `前端不应再暴露 ${marker}`); } const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-workflow-')); const statePath = path.join(tempDir, 'management.json'); const readOnly = createGiteaWorkflowManagement({ statePath }); const overview = await call(readOnly, 'GET', '/api/gitea-workflow/overview'); assert.strictEqual(overview.status, 200); assert.strictEqual(overview.body.ok, true); assert.strictEqual(overview.body.available, false); assert.strictEqual(overview.body.summary.queued, 0); const config = await call(readOnly, 'GET', '/api/gitea-workflow/config'); assert.strictEqual(config.status, 200); assert.strictEqual(config.body.secrets.webhookSecret, undefined); const invalidHost = await call(readOnly, 'PUT', '/api/gitea-workflow/config', { host: 'https://user:password@gitea.example', actor: 'alice', reason: 'invalid host test', }); assert.strictEqual(invalidHost.status, 400); assert.strictEqual(invalidHost.body.code, 'invalid_gitea_host'); const missingReason = await call(readOnly, 'POST', '/api/gitea-workflow/control/pause', { actor: 'alice' }); assert.strictEqual(missingReason.status, 400); assert.strictEqual(missingReason.body.code, 'reason_required'); const unauthorized = await call(readOnly, 'GET', '/api/gitea-workflow/overview', null, false); assert.strictEqual(unauthorized.status, 401); const unavailable = await call(readOnly, 'POST', '/api/gitea-workflow/control/pause', { actor: 'alice', reason: '维护' }); assert.strictEqual(unavailable.status, 503); assert.strictEqual(unavailable.body.code, 'workflow_service_unavailable'); const calls = []; const service = { getOverview() { return { control: { globalPaused: false }, summary: {} }; }, listRepositories() { return [{ repoKey: 'default:acme/widget', owner: 'acme', name: 'widget', status: 'active', dirty: true, dirtyReason: '未提交修改' }]; }, listTasks() { return [ { taskId: 'task-1', repoKey: 'default:acme/widget', state: 'queued', turnId: null }, { taskId: 'task-blocked', repoKey: 'default:acme/widget', state: 'blocked_workspace', errorMessage: '目录有未提交修改' }, ]; }, listAudit() { return []; }, listLogs() { return [{ level: 'info', message: 'queued' }]; }, pause(input) { calls.push(['pause', input]); return { globalPaused: true }; }, resume(input) { calls.push(['resume', input]); return { globalPaused: false }; }, disableRepository(input) { calls.push(['disable', input]); return { status: 'disabled' }; }, enableRepository(input) { calls.push(['enable', input]); return { status: 'active' }; }, cancelTask(input) { calls.push(['cancel', input]); return { state: 'cancelled' }; }, abortTask(input) { calls.push(['abort', input]); return { state: 'aborted' }; }, }; const management = createGiteaWorkflowManagement({ statePath, workflowService: service }); const listed = await call(management, 'GET', '/api/gitea-workflow/tasks?state=queued'); assert.strictEqual(listed.body.total, 1); assert.strictEqual(listed.body.items[0].taskId, 'task-1'); const overviewWithDerivedFields = await call(management, 'GET', '/api/gitea-workflow/overview'); assert.strictEqual(overviewWithDerivedFields.body.summary.maxConcurrency, undefined); assert.strictEqual(overviewWithDerivedFields.body.repositories[0].queuedCount, 1); assert.strictEqual(overviewWithDerivedFields.body.repositories[0].dirty, true); assert.match(overviewWithDerivedFields.body.repositories[0].dirtyReason, /未提交修改/); assert(overviewWithDerivedFields.body.logs.some((entry) => entry.message === 'queued')); for (const [route, expected] of [ ['/control/pause', 'pause'], ['/control/resume', 'resume'], ['/repos/default%3Aacme%2Fwidget/disable', 'disable'], ['/repos/default%3Aacme%2Fwidget/enable', 'enable'], ['/tasks/task-1/cancel', 'cancel'], ['/tasks/task-1/abort', 'abort'], ]) { const result = await call(management, 'POST', `/api/gitea-workflow${route}`, { actor: 'alice', reason: `test ${expected}`, requestId: expected }); assert.strictEqual(result.status, 200, expected); assert.strictEqual(result.body.ok, true, expected); } assert.strictEqual(calls.length, 6); const duplicate = await call(management, 'POST', '/api/gitea-workflow/control/pause', { actor: 'alice', reason: 'test pause', requestId: 'pause' }); assert.strictEqual(duplicate.status, 200); assert.strictEqual(duplicate.body.idempotent, true); assert.strictEqual(calls.length, 6, '重复 requestId 不应再次调用核心服务'); const audits = await call(management, 'GET', '/api/gitea-workflow/audit'); assert.strictEqual(audits.body.total, 6); assert(audits.body.items.every((entry) => entry.actor === 'alice' && entry.reason.startsWith('test'))); const persisted = JSON.parse(fs.readFileSync(statePath, 'utf8')); assert.strictEqual(persisted.audits.length, 6); console.log('gitea-workflow-management-unit: ok'); } main().catch((error) => { console.error(error); process.exitCode = 1; });