AI_UIAutomation/utils/ones-sync.ts

209 lines
6.5 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 OnesUpdatePayload {
uuid: string;
executor: string;
note: string;
result: 'passed' | 'failed' | 'skipped' | 'to_do';
steps: { uuid: string }[];
}
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];
}
/**
* 反写结果到 ONES 测试计划
*
* API: POST /project/api/project/team/{team_uuid}/testcase/plan/{plan_uuid}/cases/update
* Body: [{ uuid, executor, note, result, steps: [{ uuid }] }]
*/
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 };
const payload: OnesUpdatePayload[] = [];
for (const [, { caseUUID, result }] of results) {
payload.push({
uuid: caseUUID,
executor: executorUUID,
note: '',
result,
steps: [],
});
}
// 分批提交 (每批最多 50 条)
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 });
success += batch.length;
} catch (e: any) {
console.error(`ONES sync batch failed: ${e.message}`);
failed += batch.length;
}
}
return { success, failed };
}
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 };
}