muse-agent-example/工具/前端用例身份.mjs
zizi 0658d178ad 工程底座:单一生成入口、验证期写闸门与并行分片
- Makefile:新增 验收数据库(生成→检查→库层全量,前置校验隔离库连接串)、数据库分片、浏览器测试三个入口;
  格式写入 末尾就地重新生成;数据库测试 与 验收数据库 统一排除 浏览器/网络;快检 不再静默跳过类型门;
  前端旅程 预检补齐四个必需变量;pytest 目标改用仓内解释器(uv run 在嵌套检出会解析到外层环境)。
- 工具/验证锁.py:验证会话持共享锁,写入口用 --执行 在独占锁内落盘,生成/格式写入/索引生成走同一闸门。
- 工具/并行数据库测试.py:按文件分片并行,缺连接串在采集前拒绝。
- 工具/构建编排.py、环境预检.py、构建资源包.py、维护索引.py 与上述口径对齐。
- .gitignore / CI / README / AGENTS:忽略构建产物、CI 与 Makefile 单一口径、README 按实际实现陈述、AGENTS 补日常入口与验证纪律。
2026-09-18 01:14:50 +08:00

186 lines
6.2 KiB
JavaScript
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.

/** 前端用例身份与人工合同驻留测试源码,当前位置由 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;
}
}