- Makefile:新增 验收数据库(生成→检查→库层全量,前置校验隔离库连接串)、数据库分片、浏览器测试三个入口; 格式写入 末尾就地重新生成;数据库测试 与 验收数据库 统一排除 浏览器/网络;快检 不再静默跳过类型门; 前端旅程 预检补齐四个必需变量;pytest 目标改用仓内解释器(uv run 在嵌套检出会解析到外层环境)。 - 工具/验证锁.py:验证会话持共享锁,写入口用 --执行 在独占锁内落盘,生成/格式写入/索引生成走同一闸门。 - 工具/并行数据库测试.py:按文件分片并行,缺连接串在采集前拒绝。 - 工具/构建编排.py、环境预检.py、构建资源包.py、维护索引.py 与上述口径对齐。 - .gitignore / CI / README / AGENTS:忽略构建产物、CI 与 Makefile 单一口径、README 按实际实现陈述、AGENTS 补日常入口与验证纪律。
186 lines
6.2 KiB
JavaScript
186 lines
6.2 KiB
JavaScript
/** 前端用例身份与人工合同驻留测试源码,当前位置由 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;
|
||
}
|
||
}
|