AI_UIAutomation/utils/ones-sync.ts

316 lines
11 KiB
TypeScript
Raw Permalink 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];
}
/**
* 按测试名锚点构建回写 payload支持 case 级与 step 级)。
* - [ONES:<num>] → case 级结果
* - [ONES:<num>#<stepUuid>] → step 级结果
* 无锚点的结果不在此处理(交由 matchResults LCS 兜底)。
*
* 只写「跑过的」step不读取/不列出原有 step提高效率
* opts.totalStepsByNumber: 用例号 → 该用例步骤总数(来自本地参数文件)。
* 提供时:跑过的 step 数 < 总数 → case 保持 to_do部分执行全跑完才聚合 passed/failed。
* 不提供时:按已有结果聚合。
*/
export function buildAnchoredPayloads(
planCases: OnesPlanCase[],
testResults: TestResult[],
executor: string,
opts: { totalStepsByNumber?: Map<number, number> } = {}
): { 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全跑完才聚合 case 结果,否则 to_do
for (const [caseUUID, stepMap] of runSteps) {
const num = numberByUUID.get(caseUUID)!;
const steps = Array.from(stepMap.values());
const total = opts.totalStepsByNumber?.get(num);
const allRun = total != null ? stepMap.size >= total : 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 测试计划。
*/
/**
* 执行一个 GraphQL mutation经 ones CLI复用登录认证。失败抛错。
*/
function runOnesMutation(body: string): void {
const resp = runOnesGraphQL(`mutation { ${body} }`);
if (!resp || !resp.data) {
throw new Error(JSON.stringify(resp?.detail || resp || {}).slice(0, 300));
}
}
/**
* 回写 payload 到 ONES 测试计划,走 GraphQL mutationupdateTestcasePlanCase /
* updateTestcasePlanCaseStep用 ones CLI 的登录认证,无需 token/PAT。
*
* key 规则:
* case: testcase_plan_case-<planUUID>-<caseUUID>
* step: testcase_plan_case_step-<planUUID>-<caseUUID>-<stepUuid>
*/
export function postPayloads(
planUUID: string,
payload: OnesUpdatePayload[]
): { success: number; failed: number } {
let success = 0;
let failed = 0;
let stepsWritten = 0;
for (const p of payload) {
try {
// 先写 step仅跑过的
for (const s of p.steps) {
if (!s.execute_result) continue;
const sk = `testcase_plan_case_step-${planUUID}-${p.uuid}-${s.uuid}`;
const ar = s.actual_result ? `, actual_result: ${JSON.stringify(s.actual_result)}` : '';
runOnesMutation(
`updateTestcasePlanCaseStep(key: ${JSON.stringify(sk)}, step_result: ${JSON.stringify(s.execute_result)}${ar}) { key }`
);
stepsWritten++;
}
// 再写 case 级结果
const ck = `testcase_plan_case-${planUUID}-${p.uuid}`;
runOnesMutation(
`updateTestcasePlanCase(key: ${JSON.stringify(ck)}, result: ${JSON.stringify(p.result)}) { key }`
);
success++;
} catch (e: any) {
console.error(`[ONES] 用例 ${p.uuid} 回写失败: ${e.message}`);
failed++;
}
}
if (stepsWritten) console.log(`[ONES] 写入 ${stepsWritten} 个 step 结果`);
return { success, failed };
}
/**
* 反写结果到 ONES 测试计划case 级)。步骤级请用带 steps 的 payload 走 postPayloads。
*/
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);
}
/**
* 一键同步: 读取计划用例 → 匹配自动化结果 → 反写
*/
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 };
}