AI_UIAutomation/utils/ones-sync.ts

272 lines
8.9 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 是预期文本,属定义不可覆盖)
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];
}
/**
* 按测试名锚点构建回写 payload支持 case 级与 step 级)。
* - [ONES:<num>] → case 级结果
* - [ONES:<num>#<stepUuid>] → step 级结果,并聚合出 case 级结果
* 无锚点的结果不在此处理(交由 matchResults LCS 兜底)。
*/
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 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),
});
}
}
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 {
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 };
}