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 OnesStepResult { uuid: string; // 执行结果填到 execute_result(step.result 是预期文本,属定义不可覆盖) execute_result: 'passed' | 'failed' | 'skipped'; actual_result?: string; } export interface OnesUpdatePayload { uuid: string; executor: string; note: string; result: 'passed' | 'failed' | 'skipped' | 'to_do'; steps: OnesStepResult[]; } // 测试名锚点:[ONES:15974] 或 [ONES:15974#](控制为 step 级) const ONES_ANCHOR = /\[ONES:(\d+)(?:#([A-Za-z0-9_-]+))?\]/; type SyncStatus = 'passed' | 'failed' | 'skipped'; function toStatus(s: TestResult['status']): SyncStatus { return s === 'PASS' ? 'passed' : s === 'FAIL' ? 'failed' : 'skipped'; } // 聚合多个 step 结果为 case 结果:fail > skip > pass function mergeStatus(a: SyncStatus | undefined, b: SyncStatus): SyncStatus { const rank = (s?: SyncStatus) => (s === 'failed' ? 2 : s === 'skipped' ? 1 : 0); return !a || rank(b) > rank(a) ? b : a; } 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]; } /** * 按测试名锚点构建回写 payload(支持 case 级与 step 级)。 * - [ONES:] → case 级结果 * - [ONES:#] → step 级结果,并聚合出 case 级结果 * 无锚点的结果不在此处理(交由 matchResults LCS 兜底)。 */ export function buildAnchoredPayloads( planCases: OnesPlanCase[], testResults: TestResult[], executor: string ): { payloads: OnesUpdatePayload[]; unanchored: TestResult[] } { const byNumber = new Map(); for (const pc of planCases) byNumber.set(pc.caseNumber, pc); const caseResult = new Map(); const caseSteps = new Map>(); const unanchored: TestResult[] = []; for (const tr of testResults) { const m = ONES_ANCHOR.exec(tr.name); if (!m) { unanchored.push(tr); continue; } const pc = byNumber.get(parseInt(m[1], 10)); if (!pc) continue; // 锚点用例不在本计划内 const status = toStatus(tr.status); caseResult.set(pc.caseUUID, mergeStatus(caseResult.get(pc.caseUUID), status)); if (m[2]) { if (!caseSteps.has(pc.caseUUID)) caseSteps.set(pc.caseUUID, new Map()); caseSteps.get(pc.caseUUID)!.set(m[2], { uuid: m[2], execute_result: status, actual_result: (tr.detail || '').slice(0, 500), }); } } const payloads: OnesUpdatePayload[] = []; for (const [caseUUID, result] of caseResult) { const steps = caseSteps.has(caseUUID) ? Array.from(caseSteps.get(caseUUID)!.values()) : []; payloads.push({ uuid: caseUUID, executor, note: '', result, steps }); } return { payloads, unanchored }; } /** * 分批 POST 回写 payload 到 ONES 测试计划。 */ export function postPayloads( planUUID: string, payload: OnesUpdatePayload[] ): { success: number; failed: number } { if (payload.length === 0) return { success: 0, failed: 0 }; 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 { execSync(buildCurlCommand(planUUID, batch), { 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 }; } /** * 反写结果到 ONES 测试计划 * * API: POST /project/api/project/team/{team_uuid}/testcase/plan/{plan_uuid}/cases/update * Body: { cases: [{ uuid, executor, note, result, steps: [{ uuid, execute_result }] }] } */ export function syncResultsToOnes( planUUID: string, results: Map, executorUUID: string ): { success: number; failed: number } { const payload: OnesUpdatePayload[] = []; for (const [, { caseUUID, result }] of results) { payload.push({ uuid: caseUUID, executor: executorUUID, note: '', result, steps: [] }); } return postPayloads(planUUID, payload); } 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 }; }