316 lines
11 KiB
TypeScript
316 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];
|
||
}
|
||
|
||
/**
|
||
* 按测试名锚点构建回写 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 mutation(updateTestcasePlanCase /
|
||
* 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 };
|
||
}
|