318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
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_result(step.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 };
|
||
}
|