From 88435b0ffc4149b063dbfd687816ed98b4a1fa41 Mon Sep 17 00:00:00 2001 From: woan <798680981@qq.com> Date: Fri, 29 May 2026 15:01:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(ones-sync):=20step=20=E7=BA=A7=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=9B=9E=E5=86=99=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildAnchoredPayloads: 按测试名锚点 [ONES:号(#step)] 精确匹配, step 级结果填入 step.execute_result,并聚合出 case 级结果(fail>skip>pass) - postPayloads: 抽出的批量 POST,syncResultsToOnes 复用 - sync-ones-results.ts: 锚点优先 + LCS 兜底,dry-run 打印完整 payload 供核对 注: 控制必测 2 条超级用例(15974/15975)按 step 回写;step 执行结果字段 用 execute_result(待一次受控 live 写入验证字段被接受)。 Co-Authored-By: Claude Opus 4.8 --- scripts/sync-ones-results.ts | 58 +++++++++++------ utils/ones-sync.ts | 119 ++++++++++++++++++++++++++--------- 2 files changed, 131 insertions(+), 46 deletions(-) diff --git a/scripts/sync-ones-results.ts b/scripts/sync-ones-results.ts index 3a29f97..52e33f1 100644 --- a/scripts/sync-ones-results.ts +++ b/scripts/sync-ones-results.ts @@ -7,14 +7,21 @@ * 流程: * 1. 读取 reports/.results.json (自动化执行后的结果) * 2. 从 ONES 拉取测试计划用例列表 - * 3. 按用例名称匹配 - * 4. 反写匹配成功的结果到 ONES + * 3. 优先按测试名锚点 [ONES:号(#step)] 精确匹配(支持 step 级回写), + * 无锚点的回退到用例名 LCS 模糊匹配 + * 4. 反写结果到 ONES (dry-run 仅打印 payload) */ import * as fs from 'fs'; import * as path from 'path'; import { TestResult } from '../utils/test-reporter'; -import { fetchPlanCases, matchResults, syncResultsToOnes } from '../utils/ones-sync'; +import { + fetchPlanCases, + matchResults, + buildAnchoredPayloads, + postPayloads, + OnesUpdatePayload, +} from '../utils/ones-sync'; import { execSync } from 'child_process'; const ONES_CLI = '/Users/woan/local/bin/ones'; @@ -56,6 +63,9 @@ function loadResults(): TestResult[] { function main() { const { planUUID, dryRun } = parseArgs(); + const config = JSON.parse(execSync(`${ONES_CLI} config show`, { encoding: 'utf-8' })); + const executor = config.user_id; + console.log('='.repeat(60)); console.log(' ONES 测试计划结果同步'); console.log('='.repeat(60)); @@ -76,29 +86,41 @@ function main() { const planCases = fetchPlanCases(planUUID); console.log(` 计划共 ${planCases.length} 条用例`); - // 3. 匹配 - console.log(`\n[3/4] 匹配自动化结果到 ONES 用例 ...`); - const matched = matchResults(planCases, testResults); - console.log(` 匹配成功: ${matched.size} / ${testResults.length}`); + // 3. 匹配:锚点优先(支持 step 级),无锚点回退 LCS + console.log(`\n[3/4] 匹配 (锚点优先 + LCS 兜底) ...`); + const { payloads: anchored, unanchored } = buildAnchoredPayloads(planCases, testResults, executor); + const anchoredUUIDs = new Set(anchored.map(p => p.uuid)); + const stepCount = anchored.reduce((n, p) => n + p.steps.length, 0); + console.log(` 锚点匹配: ${anchored.length} 用例 (含 ${stepCount} 个 step 级结果)`); - if (matched.size > 0) { - console.log('\n 匹配详情:'); - for (const [caseUUID, { result }] of matched) { - const pc = planCases.find(c => c.caseUUID === caseUUID); - const icon = result === 'passed' ? '✓' : result === 'failed' ? '✗' : '○'; - console.log(` ${icon} [${result}] ${pc?.caseName || caseUUID}`); + // LCS 兜底:仅补充锚点未覆盖的用例 + const lcs = matchResults(planCases, unanchored); + const lcsPayloads: OnesUpdatePayload[] = []; + for (const [, { caseUUID, result }] of lcs) { + if (anchoredUUIDs.has(caseUUID)) continue; + lcsPayloads.push({ uuid: caseUUID, executor, note: '', result, steps: [] }); + } + console.log(` LCS 兜底: ${lcsPayloads.length} 用例 (未锚点剩余 ${unanchored.length} 条)`); + + const payloads = [...anchored, ...lcsPayloads]; + if (payloads.length > 0) { + console.log('\n 回写预览:'); + for (const p of payloads) { + const pc = planCases.find(c => c.caseUUID === p.uuid); + const icon = p.result === 'passed' ? '✓' : p.result === 'failed' ? '✗' : '○'; + const stepInfo = p.steps.length ? ` (${p.steps.length} steps)` : ''; + console.log(` ${icon} [${p.result}] ${pc?.caseName || p.uuid}${stepInfo}`); } } // 4. 反写 if (dryRun) { - console.log(`\n[4/4] DRY-RUN 模式,跳过实际写入`); - console.log(` 将会更新 ${matched.size} 条用例结果`); + console.log(`\n[4/4] DRY-RUN:将更新 ${payloads.length} 条用例,跳过实际写入`); + console.log(' 完整 payload (供核对 step 字段格式):'); + console.log(JSON.stringify({ cases: payloads }, null, 2)); } else { console.log(`\n[4/4] 反写结果到 ONES ...`); - const configStr = execSync(`${ONES_CLI} config show`, { encoding: 'utf-8' }); - const config = JSON.parse(configStr); - const { success, failed: failCount } = syncResultsToOnes(planUUID, matched, config.user_id); + const { success, failed: failCount } = postPayloads(planUUID, payloads); console.log(` 成功: ${success} | 失败: ${failCount}`); } diff --git a/utils/ones-sync.ts b/utils/ones-sync.ts index d359935..af15f59 100644 --- a/utils/ones-sync.ts +++ b/utils/ones-sync.ts @@ -12,12 +12,34 @@ export interface OnesPlanCase { 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: { uuid: string }[]; + 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 { @@ -119,52 +141,93 @@ function lcs(a: string, b: string): number { } /** - * 反写结果到 ONES 测试计划 - * - * API: POST /project/api/project/team/{team_uuid}/testcase/plan/{plan_uuid}/cases/update - * Body: [{ uuid, executor, note, result, steps: [{ uuid }] }] + * 按测试名锚点构建回写 payload(支持 case 级与 step 级)。 + * - [ONES:] → case 级结果 + * - [ONES:#] → step 级结果,并聚合出 case 级结果 + * 无锚点的结果不在此处理(交由 matchResults LCS 兜底)。 */ -export function syncResultsToOnes( - planUUID: string, - results: Map, - executorUUID: string -): { success: number; failed: number } { - if (results.size === 0) return { success: 0, failed: 0 }; +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 payload: OnesUpdatePayload[] = []; - for (const [, { caseUUID, result }] of results) { - payload.push({ - uuid: caseUUID, - executor: executorUUID, - note: '', - result, - steps: [], - }); + 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), + }); + } } - // 分批提交 (每批最多 50 条) + 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 { - 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 }); + 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);