/** 前端用例身份与人工合同驻留测试源码,当前位置由 TypeScript AST 派生。 */ import { readFileSync, readdirSync } from "node:fs"; import { createRequire } from "node:module"; import { resolve, relative, join } from "node:path"; import { fileURLToPath } from "node:url"; const 位置字段 = new Set([ "file", "symbol", "line", "parameter_ids", "node_ids", "fixtures", "markers", ]); const 标签 = "@muse-case "; export function 分析文件(root, file) { const ts = createRequire(join(resolve(root), "web/package.json"))( "typescript", ); const text = readFileSync(file, "utf8"); const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); if (source.parseDiagnostics.length) throw new Error(`TypeScript 解析失败:${file}`); const testers = new Set(); for (const node of source.statements) { if ( !ts.isImportDeclaration(node) || !["vitest", "@playwright/test"].includes(node.moduleSpecifier.text) ) continue; const bindings = node.importClause?.namedBindings; if (bindings && ts.isNamedImports(bindings)) { for (const item of bindings.elements) { if (["test", "it"].includes((item.propertyName ?? item.name).text)) testers.add(item.name.text); } } } const declarations = []; const metadataComments = new Set(); function visit(node) { for (const comment of [ ...(ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []), ...(ts.getTrailingCommentRanges(text, node.end) ?? []), ]) { if (text.slice(comment.pos, comment.end).includes(标签)) metadataComments.add(comment.pos); } if (ts.isCallExpression(node)) { let callee = node.expression; const modifiers = []; while (ts.isPropertyAccessExpression(callee)) { modifiers.push(callee.name.text); callee = callee.expression; } if ( ts.isIdentifier(callee) && testers.has(callee.text) && modifiers.every((m) => [ "only", "skip", "todo", "fails", "concurrent", "sequential", "fixme", ].includes(m), ) ) { const title = node.arguments[0]; if (!title || !ts.isStringLiteralLike(title)) throw new Error( `测试标题必须是静态完整身份:${file}:${source.getLineAndCharacterOfPosition(node.pos).line + 1}`, ); const start = node.getStart(source); const comments = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []; const adjacent = comments.filter((c) => text.slice(c.pos, c.end).includes(标签), ); if (adjacent.length > 1) throw new Error(`同一测试出现多个身份:${file}:${title.text}`); let metadata = null; let comment = null; if (adjacent.length) { comment = adjacent[0]; const raw = text.slice(comment.pos, comment.end); if ( !raw.startsWith(`// ${标签}`) || text.slice(comment.end, start).trim() ) throw new Error(`用例元信息须紧邻测试声明:${file}:${title.text}`); metadata = JSON.parse(raw.slice(3 + 标签.length)); } declarations.push({ title: title.text, start, titleStart: title.getStart(source), titleEnd: title.end, comment, metadata, }); } // each 的展开须显式赋予各行独立身份,不能静默漏收。 if ( ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "each" ) { let owner = node.expression.expression; while (ts.isPropertyAccessExpression(owner)) owner = owner.expression; if (ts.isIdentifier(owner) && testers.has(owner.text)) throw new Error(`参数化测试请逐行声明稳定身份:${file}`); } } ts.forEachChild(node, visit); } visit(source); if (!declarations.length) throw new Error(`测试文件没有可收集的具名测试:${file}`); if ( metadataComments.size !== declarations.filter((d) => d.metadata !== null).length ) throw new Error(`存在未绑定测试的用例元信息:${file}`); return { text, declarations }; } export function 测试文件(root) { const result = []; function walk(dir) { for (const item of readdirSync(dir, { withFileTypes: true })) { if (item.isDirectory()) walk(join(dir, item.name)); else if (/\.(test|spec)\.[jt]sx?$/.test(item.name)) result.push(join(dir, item.name)); } } walk(join(root, "web/tests")); walk(join(root, "web/src")); return result.sort(); } export function 收集前端用例(root) { root = resolve(root); const seen = new Set(); const result = []; for (const file of 测试文件(root)) { for (const { title, metadata } of 分析文件(root, file).declarations) { if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) throw new Error(`缺失用例元信息:${file}:${title}`); const id = metadata.case_id; if (typeof id !== "string" || !/^(?:TC|NC|O\d+)-[^\s::]+$/.test(id)) throw new Error(`无效完整身份:${file}:${id}`); if (!title.startsWith(`${id}:`)) throw new Error(`元信息与测试身份漂移:${file}:${id} / ${title}`); if (seen.has(id)) throw new Error(`重复用例身份:${id}`); seen.add(id); for (const key of Object.keys(metadata)) if (位置字段.has(key)) throw new Error(`元信息不得登记派生位置 ${key}:${id}`); result.push({ ...metadata, file: relative(root, file).replaceAll("\\", "/"), symbol: title, }); } } return result.sort((a, b) => a.case_id.localeCompare(b.case_id, "en")); } if ( process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { try { process.stdout.write( `${JSON.stringify(收集前端用例(process.argv[2] ?? process.cwd()), null, 2)}\n`, ); } catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; } }