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:
parent
cf994c1aad
commit
88435b0ffc
|
|
@ -7,14 +7,21 @@
|
||||||
* 流程:
|
* 流程:
|
||||||
* 1. 读取 reports/.results.json (自动化执行后的结果)
|
* 1. 读取 reports/.results.json (自动化执行后的结果)
|
||||||
* 2. 从 ONES 拉取测试计划用例列表
|
* 2. 从 ONES 拉取测试计划用例列表
|
||||||
* 3. 按用例名称匹配
|
* 3. 优先按测试名锚点 [ONES:号(#step)] 精确匹配(支持 step 级回写),
|
||||||
* 4. 反写匹配成功的结果到 ONES
|
* 无锚点的回退到用例名 LCS 模糊匹配
|
||||||
|
* 4. 反写结果到 ONES (dry-run 仅打印 payload)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { TestResult } from '../utils/test-reporter';
|
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';
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
const ONES_CLI = '/Users/woan/local/bin/ones';
|
const ONES_CLI = '/Users/woan/local/bin/ones';
|
||||||
|
|
@ -56,6 +63,9 @@ function loadResults(): TestResult[] {
|
||||||
function main() {
|
function main() {
|
||||||
const { planUUID, dryRun } = parseArgs();
|
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('='.repeat(60));
|
||||||
console.log(' ONES 测试计划结果同步');
|
console.log(' ONES 测试计划结果同步');
|
||||||
console.log('='.repeat(60));
|
console.log('='.repeat(60));
|
||||||
|
|
@ -76,29 +86,41 @@ function main() {
|
||||||
const planCases = fetchPlanCases(planUUID);
|
const planCases = fetchPlanCases(planUUID);
|
||||||
console.log(` 计划共 ${planCases.length} 条用例`);
|
console.log(` 计划共 ${planCases.length} 条用例`);
|
||||||
|
|
||||||
// 3. 匹配
|
// 3. 匹配:锚点优先(支持 step 级),无锚点回退 LCS
|
||||||
console.log(`\n[3/4] 匹配自动化结果到 ONES 用例 ...`);
|
console.log(`\n[3/4] 匹配 (锚点优先 + LCS 兜底) ...`);
|
||||||
const matched = matchResults(planCases, testResults);
|
const { payloads: anchored, unanchored } = buildAnchoredPayloads(planCases, testResults, executor);
|
||||||
console.log(` 匹配成功: ${matched.size} / ${testResults.length}`);
|
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) {
|
// LCS 兜底:仅补充锚点未覆盖的用例
|
||||||
console.log('\n 匹配详情:');
|
const lcs = matchResults(planCases, unanchored);
|
||||||
for (const [caseUUID, { result }] of matched) {
|
const lcsPayloads: OnesUpdatePayload[] = [];
|
||||||
const pc = planCases.find(c => c.caseUUID === caseUUID);
|
for (const [, { caseUUID, result }] of lcs) {
|
||||||
const icon = result === 'passed' ? '✓' : result === 'failed' ? '✗' : '○';
|
if (anchoredUUIDs.has(caseUUID)) continue;
|
||||||
console.log(` ${icon} [${result}] ${pc?.caseName || caseUUID}`);
|
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. 反写
|
// 4. 反写
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
console.log(`\n[4/4] DRY-RUN 模式,跳过实际写入`);
|
console.log(`\n[4/4] DRY-RUN:将更新 ${payloads.length} 条用例,跳过实际写入`);
|
||||||
console.log(` 将会更新 ${matched.size} 条用例结果`);
|
console.log(' 完整 payload (供核对 step 字段格式):');
|
||||||
|
console.log(JSON.stringify({ cases: payloads }, null, 2));
|
||||||
} else {
|
} else {
|
||||||
console.log(`\n[4/4] 反写结果到 ONES ...`);
|
console.log(`\n[4/4] 反写结果到 ONES ...`);
|
||||||
const configStr = execSync(`${ONES_CLI} config show`, { encoding: 'utf-8' });
|
const { success, failed: failCount } = postPayloads(planUUID, payloads);
|
||||||
const config = JSON.parse(configStr);
|
|
||||||
const { success, failed: failCount } = syncResultsToOnes(planUUID, matched, config.user_id);
|
|
||||||
console.log(` 成功: ${success} | 失败: ${failCount}`);
|
console.log(` 成功: ${success} | 失败: ${failCount}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,34 @@ export interface OnesPlanCase {
|
||||||
executor?: string;
|
executor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OnesStepResult {
|
||||||
|
uuid: string;
|
||||||
|
// 执行结果填到 execute_result(step.result 是预期文本,属定义不可覆盖)
|
||||||
|
execute_result: 'passed' | 'failed' | 'skipped';
|
||||||
|
actual_result?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OnesUpdatePayload {
|
export interface OnesUpdatePayload {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
executor: string;
|
executor: string;
|
||||||
note: string;
|
note: string;
|
||||||
result: 'passed' | 'failed' | 'skipped' | 'to_do';
|
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 {
|
function runOnesGraphQL(query: string): any {
|
||||||
|
|
@ -119,52 +141,93 @@ function lcs(a: string, b: string): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 反写结果到 ONES 测试计划
|
* 按测试名锚点构建回写 payload(支持 case 级与 step 级)。
|
||||||
*
|
* - [ONES:<num>] → case 级结果
|
||||||
* API: POST /project/api/project/team/{team_uuid}/testcase/plan/{plan_uuid}/cases/update
|
* - [ONES:<num>#<stepUuid>] → step 级结果,并聚合出 case 级结果
|
||||||
* Body: [{ uuid, executor, note, result, steps: [{ uuid }] }]
|
* 无锚点的结果不在此处理(交由 matchResults LCS 兜底)。
|
||||||
*/
|
*/
|
||||||
export function syncResultsToOnes(
|
export function buildAnchoredPayloads(
|
||||||
planUUID: string,
|
planCases: OnesPlanCase[],
|
||||||
results: Map<string, { caseUUID: string; result: 'passed' | 'failed' | 'skipped' }>,
|
testResults: TestResult[],
|
||||||
executorUUID: string
|
executor: string
|
||||||
): { success: number; failed: number } {
|
): { payloads: OnesUpdatePayload[]; unanchored: TestResult[] } {
|
||||||
if (results.size === 0) return { success: 0, failed: 0 };
|
const byNumber = new Map<number, OnesPlanCase>();
|
||||||
|
for (const pc of planCases) byNumber.set(pc.caseNumber, pc);
|
||||||
|
|
||||||
const payload: OnesUpdatePayload[] = [];
|
const caseResult = new Map<string, SyncStatus>();
|
||||||
for (const [, { caseUUID, result }] of results) {
|
const caseSteps = new Map<string, Map<string, OnesStepResult>>();
|
||||||
payload.push({
|
const unanchored: TestResult[] = [];
|
||||||
uuid: caseUUID,
|
|
||||||
executor: executorUUID,
|
for (const tr of testResults) {
|
||||||
note: '',
|
const m = ONES_ANCHOR.exec(tr.name);
|
||||||
result,
|
if (!m) {
|
||||||
steps: [],
|
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;
|
const batchSize = 50;
|
||||||
let success = 0;
|
let success = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
for (let i = 0; i < payload.length; i += batchSize) {
|
for (let i = 0; i < payload.length; i += batchSize) {
|
||||||
const batch = payload.slice(i, i + batchSize);
|
const batch = payload.slice(i, i + batchSize);
|
||||||
try {
|
try {
|
||||||
const bodyJson = JSON.stringify(batch);
|
execSync(buildCurlCommand(planUUID, batch), { encoding: 'utf-8', timeout: 30000 });
|
||||||
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;
|
success += batch.length;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(`ONES sync batch failed: ${e.message}`);
|
console.error(`ONES sync batch failed: ${e.message}`);
|
||||||
failed += batch.length;
|
failed += batch.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success, failed };
|
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 {
|
function buildCurlCommand(planUUID: string, payload: OnesUpdatePayload[]): string {
|
||||||
const configStr = execSync(`${ONES_CLI} config show`, { encoding: 'utf-8' });
|
const configStr = execSync(`${ONES_CLI} config show`, { encoding: 'utf-8' });
|
||||||
const config = JSON.parse(configStr);
|
const config = JSON.parse(configStr);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue