AI_UIAutomation/utils/ones-sync.ts

318 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_resultstep.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#<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 {
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<string, { caseUUID: string; result: 'passed' | 'failed' | 'skipped' }> {
const matched = new Map<string, { caseUUID: string; result: 'passed' | 'failed' | 'skipped' }>();
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];
}
/**
* 取某用例的全部 step uuid按定义顺序用于回写时列全量 step。
*/
export function fetchCaseSteps(caseNumber: number): string[] {
try {
const out = execSync(`${ONES_CLI} testcase case search --key ${caseNumber}`, {
encoding: 'utf-8',
timeout: 30000,
});
const c = JSON.parse(out).cases?.[0];
return (c?.steps || []).map((s: any) => s.uuid);
} catch {
return [];
}
}
/**
* 按测试名锚点构建回写 payload支持 case 级与 step 级)。
* - [ONES:<num>] → case 级结果
* - [ONES:<num>#<stepUuid>] → step 级结果
* 无锚点的结果不在此处理(交由 matchResults LCS 兜底)。
*
* opts.fullStepsByNumber: 用例号 → 全部 step uuid。提供时按 ONES 要求列全量 step
* 只给跑过的填 execute_result未跑的仅列 uuid全部跑完才聚合 case 结果,否则保持 to_do。
*/
export function buildAnchoredPayloads(
planCases: OnesPlanCase[],
testResults: TestResult[],
executor: string,
opts: { fullStepsByNumber?: Map<number, string[]> } = {}
): { payloads: OnesUpdatePayload[]; unanchored: TestResult[] } {
const byNumber = new Map<number, OnesPlanCase>();
for (const pc of planCases) byNumber.set(pc.caseNumber, pc);
const caseLevel = new Map<string, SyncStatus>(); // caseUUID -> result (add/feature)
const runSteps = new Map<string, Map<string, OnesStepResult>>(); // caseUUID -> stepUuid -> result
const numberByUUID = new Map<string, number>();
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未跑的仅 uuid全部跑完才聚合 case 结果
for (const [caseUUID, stepMap] of runSteps) {
const num = numberByUUID.get(caseUUID)!;
const full = opts.fullStepsByNumber?.get(num);
let steps: OnesStepResult[];
let allRun: boolean;
if (full && full.length) {
steps = full.map((uuid) => stepMap.get(uuid) ?? { uuid });
allRun = full.every((uuid) => stepMap.has(uuid));
} else {
steps = Array.from(stepMap.values());
allRun = 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 测试计划。
*/
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<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);
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 };
}