feat(ones-sync): step 级结果回写支持

- 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 <noreply@anthropic.com>
This commit is contained in:
woan 2026-05-29 15:01:10 +08:00
parent cf994c1aad
commit 88435b0ffc
2 changed files with 131 additions and 46 deletions

View File

@ -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}`);
}

View File

@ -12,12 +12,34 @@ export interface OnesPlanCase {
executor?: string;
}
export interface OnesStepResult {
uuid: string;
// 执行结果填到 execute_resultstep.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#<stepUuid>](控制为 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:<num>] case
* - [ONES:<num>#<stepUuid>] step case
* matchResults LCS
*/
export function syncResultsToOnes(
planUUID: string,
results: Map<string, { caseUUID: string; result: 'passed' | 'failed' | 'skipped' }>,
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<number, OnesPlanCase>();
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<string, SyncStatus>();
const caseSteps = new Map<string, Map<string, OnesStepResult>>();
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<string, { caseUUID: string; result: 'passed' | 'failed' | 'skipped' }>,
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);