import { execSync } from 'child_process'; import { TestResult } from './test-reporter'; const ONES_CLI = '/Users/woan/local/bin/ones'; export interface OnesPlanCase { key: string; caseUUID: string; caseName: string; caseNumber: number; currentResult: string; executor?: string; } export interface OnesUpdatePayload { uuid: string; executor: string; note: string; result: 'passed' | 'failed' | 'skipped' | 'to_do'; steps: { uuid: string }[]; } function runOnesGraphQL(query: string): any { const cmd = `${ONES_CLI} graphql '${query.replace(/'/g, "'\\''")}'`; const output = execSync(cmd, { encoding: 'utf-8', timeout: 30000 }); return JSON.parse(output); } /** * 从 ONES 测试计划读取所有用例 */ export function fetchPlanCases(planUUID: string): OnesPlanCase[] { const results: OnesPlanCase[] = []; let offset = 0; const limit = 100; while (true) { const query = `{ testcasePlanCases(filter: { testcasePlan: { uuid_in: ["${planUUID}"] } }, limit: ${limit}, offset: ${offset}) { key result executor { uuid } testcaseCase { uuid name number } } }`; const resp = runOnesGraphQL(query); const cases = resp?.data?.testcasePlanCases || []; if (cases.length === 0) break; for (const c of cases) { results.push({ key: c.key, caseUUID: c.testcaseCase.uuid, caseName: c.testcaseCase.name, caseNumber: c.testcaseCase.number, currentResult: c.result || 'to_do', executor: c.executor?.uuid, }); } if (cases.length < limit) break; offset += limit; } return results; } /** * 将自动化测试结果映射到 ONES 用例 (按用例名称模糊匹配) */ export function matchResults( planCases: OnesPlanCase[], testResults: TestResult[] ): Map { const matched = new Map(); for (const tr of testResults) { const result = tr.status === 'PASS' ? 'passed' : tr.status === 'FAIL' ? 'failed' : 'skipped'; // 精确匹配: 用例名完全包含在 ONES 用例名中,或反之 let bestMatch: OnesPlanCase | null = null; let bestScore = 0; for (const pc of planCases) { const score = similarityScore(tr.name, pc.caseName); if (score > bestScore && score >= 0.5) { bestScore = score; bestMatch = pc; } } if (bestMatch) { matched.set(bestMatch.caseUUID, { caseUUID: bestMatch.caseUUID, result }); } } return matched; } /** * 计算两个字符串的相似度 (0~1) * 基于最长公共子序列 */ function similarityScore(a: string, b: string): number { const aNorm = a.replace(/[\s\-_]/g, '').toLowerCase(); const bNorm = b.replace(/[\s\-_]/g, '').toLowerCase(); if (aNorm === bNorm) return 1; if (aNorm.includes(bNorm) || bNorm.includes(aNorm)) return 0.9; // LCS ratio const lcsLen = lcs(aNorm, bNorm); return (2 * lcsLen) / (aNorm.length + bNorm.length); } function lcs(a: string, b: string): number { const m = a.length, n = b.length; if (m === 0 || n === 0) return 0; const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); } } return dp[m][n]; } /** * 反写结果到 ONES 测试计划 * * API: POST /project/api/project/team/{team_uuid}/testcase/plan/{plan_uuid}/cases/update * Body: [{ uuid, executor, note, result, steps: [{ uuid }] }] */ export function syncResultsToOnes( planUUID: string, results: Map, executorUUID: string ): { success: number; failed: number } { if (results.size === 0) return { success: 0, failed: 0 }; const payload: OnesUpdatePayload[] = []; for (const [, { caseUUID, result }] of results) { payload.push({ uuid: caseUUID, executor: executorUUID, note: '', result, steps: [], }); } // 分批提交 (每批最多 50 条) const batchSize = 50; let success = 0; let failed = 0; for (let i = 0; i < payload.length; i += batchSize) { const batch = payload.slice(i, i + batchSize); try { const bodyJson = JSON.stringify(batch); const cmd = `${ONES_CLI} graphql --raw-post "/testcase/plan/${planUUID}/cases/update" '${bodyJson.replace(/'/g, "'\\''")}'`; // 由于 ones CLI 可能不支持 raw-post,直接用 curl const curlCmd = buildCurlCommand(planUUID, batch); execSync(curlCmd, { encoding: 'utf-8', timeout: 30000 }); success += batch.length; } catch (e: any) { console.error(`ONES sync batch failed: ${e.message}`); failed += batch.length; } } return { success, failed }; } function buildCurlCommand(planUUID: string, payload: OnesUpdatePayload[]): string { const configStr = execSync(`${ONES_CLI} config show`, { encoding: 'utf-8' }); const config = JSON.parse(configStr); const { base_url, team_uuid, token } = config; const url = `${base_url}/project/api/project/team/${team_uuid}/testcase/plan/${planUUID}/cases/update`; const body = JSON.stringify({ cases: payload }).replace(/'/g, "'\\''"); return `curl -s -X POST '${url}' -H 'Authorization: Bearer ${token}' -H 'Content-Type: application/json' -d '${body}'`; } /** * 一键同步: 读取计划用例 → 匹配自动化结果 → 反写 */ export function fullSync( planUUID: string, testResults: TestResult[], executorUUID?: string ): { total: number; matched: number; synced: number; failed: number } { const configStr = execSync(`${ONES_CLI} config show`, { encoding: 'utf-8' }); const config = JSON.parse(configStr); const executor = executorUUID || config.user_id; console.log(`[ONES Sync] 读取测试计划 ${planUUID} ...`); const planCases = fetchPlanCases(planUUID); console.log(`[ONES Sync] 计划共 ${planCases.length} 条用例`); console.log(`[ONES Sync] 匹配自动化结果 (${testResults.length} 条) ...`); const matched = matchResults(planCases, testResults); console.log(`[ONES Sync] 匹配成功 ${matched.size} 条`); if (matched.size === 0) { return { total: planCases.length, matched: 0, synced: 0, failed: 0 }; } console.log(`[ONES Sync] 反写结果到 ONES ...`); const { success, failed } = syncResultsToOnes(planUUID, matched, executor); console.log(`[ONES Sync] 完成: ${success} 成功, ${failed} 失败`); return { total: planCases.length, matched: matched.size, synced: success, failed }; }