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 是预期文本,属定义不可覆盖)。 // 未执行的 step 只列 uuid、不带 execute_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 级结果 * 无锚点的结果不在此处理(交由 matchResults LCS 兜底)。 * * 只写「跑过的」step(不读取/不列出原有 step,提高效率)。 * opts.totalStepsByNumber: 用例号 → 该用例步骤总数(来自本地参数文件)。 * 提供时:跑过的 step 数 < 总数 → case 保持 to_do(部分执行);全跑完才聚合 passed/failed。 * 不提供时:按已有结果聚合。 */ export function buildAnchoredPayloads( planCases: OnesPlanCase[], testResults: TestResult[], executor: string, opts: { totalStepsByNumber?: Map } = {} ): { payloads: OnesUpdatePayload[]; unanchored: TestResult[] } { const byNumber = new Map(); for (const pc of planCases) byNumber.set(pc.caseNumber, pc); const caseLevel = new Map(); // caseUUID -> result (add/feature) const runSteps = new Map>(); // caseUUID -> stepUuid -> result const numberByUUID = 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; // 锚点用例不在本计划内 numberByUUID.set(pc.caseUUID, pc.caseNumber); const status = toStatus(tr.status); if (m[2]) { if (!runSteps.has(pc.caseUUID)) runSteps.set(pc.caseUUID, new Map()); runSteps.get(pc.caseUUID)!.set(m[2], { uuid: m[2], execute_result: status, actual_result: (tr.detail || '').slice(0, 500), }); } else { caseLevel.set(pc.caseUUID, mergeStatus(caseLevel.get(pc.caseUUID), status)); } } const payloads: OnesUpdatePayload[] = []; // step 级用例:只写跑过的 step;全跑完才聚合 case 结果,否则 to_do for (const [caseUUID, stepMap] of runSteps) { const num = numberByUUID.get(caseUUID)!; const steps = Array.from(stepMap.values()); const total = opts.totalStepsByNumber?.get(num); const allRun = total != null ? stepMap.size >= total : true; let agg: SyncStatus | undefined; for (const s of stepMap.values()) if (s.execute_result) agg = mergeStatus(agg, s.execute_result); const result: OnesUpdatePayload['result'] = allRun ? agg ?? 'to_do' : 'to_do'; payloads.push({ uuid: caseUUID, executor, note: '', result, steps }); } // case 级用例(添加/功能,无 step 结果) for (const [caseUUID, result] of caseLevel) { if (runSteps.has(caseUUID)) continue; payloads.push({ uuid: caseUUID, executor, note: '', result, steps: [] }); } return { payloads, unanchored }; } /** * 分批 POST 回写 payload 到 ONES 测试计划。 */ /** * 执行一个 GraphQL mutation(经 ones CLI,复用登录认证)。失败抛错。 */ function runOnesMutation(body: string): void { const resp = runOnesGraphQL(`mutation { ${body} }`); if (!resp || !resp.data) { throw new Error(JSON.stringify(resp?.detail || resp || {}).slice(0, 300)); } } /** * 回写 payload 到 ONES 测试计划,走 GraphQL mutation(updateTestcasePlanCase / * updateTestcasePlanCaseStep),用 ones CLI 的登录认证,无需 token/PAT。 * * key 规则: * case: testcase_plan_case-- * step: testcase_plan_case_step--- */ export function postPayloads( planUUID: string, payload: OnesUpdatePayload[] ): { success: number; failed: number } { let success = 0; let failed = 0; let stepsWritten = 0; for (const p of payload) { try { // 先写 step(仅跑过的) for (const s of p.steps) { if (!s.execute_result) continue; const sk = `testcase_plan_case_step-${planUUID}-${p.uuid}-${s.uuid}`; const ar = s.actual_result ? `, actual_result: ${JSON.stringify(s.actual_result)}` : ''; runOnesMutation( `updateTestcasePlanCaseStep(key: ${JSON.stringify(sk)}, step_result: ${JSON.stringify(s.execute_result)}${ar}) { key }` ); stepsWritten++; } // 再写 case 级结果 const ck = `testcase_plan_case-${planUUID}-${p.uuid}`; runOnesMutation( `updateTestcasePlanCase(key: ${JSON.stringify(ck)}, result: ${JSON.stringify(p.result)}) { key }` ); success++; } catch (e: any) { console.error(`[ONES] 用例 ${p.uuid} 回写失败: ${e.message}`); failed++; } } if (stepsWritten) console.log(`[ONES] 写入 ${stepsWritten} 个 step 结果`); return { success, failed }; } /** * 反写结果到 ONES 测试计划(case 级)。步骤级请用带 steps 的 payload 走 postPayloads。 */ 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); } /** * 一键同步: 读取计划用例 → 匹配自动化结果 → 反写 */ 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 }; }