Files
Ncurator/scripts/test/validate-test-architecture.mjs
huangyuan 508132adba perf(test): reuse knowledge computations and expand skill live evaluation
Share content-addressed parsing and embedding caches across isolated test runs, fingerprint relevant dependencies and model assets, and remove implicit UI warm-up. Add realistic two-round Skill delivery evaluation and cache regressions.
2026-10-23 18:50:49 +08:00

346 lines
13 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.
import { readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
const ROOT = process.cwd()
const CASE_ROOT = path.join(ROOT, 'tests', 'case')
const PLAN_ROOT = path.join(ROOT, 'doc', 'test-plan')
const PRD_ROOT = path.join(ROOT, 'doc', 'prd')
const DOMAIN_RULES = {
ai: { type: 'backend', plan: 'ai-test.md' },
knowledge: { type: 'backend', plan: 'knowledge-test.md' },
settings: { type: 'e2e', plan: 'settings-test.md', prd: 'settings.md', acPrefixes: ['SET'] },
workbench: {
type: 'e2e',
plan: 'workbench-test.md',
prd: 'workbench.md',
acPrefixes: ['WB', 'ST']
},
unit: { type: 'backend', plan: '' }
}
const PLAN_FIELDS = ['测试类型', 'What', 'Why', 'How', 'Oracle']
// References register acceptance ownership; they do not prove assertion coverage.
const acceptanceReferences = (source, prefixes) => {
const text = source.replaceAll('`', '')
const pattern = new RegExp(`\\b((?:${prefixes.join('|')})-[A-Z0-9-]+)-AC(\\d+)\\b`, 'gu')
const references = new Set()
for (const match of text.matchAll(pattern)) {
const requirement = match[1]
let last = Number(match[2])
references.add(`${requirement}-AC${last}`)
let tail = text.slice(match.index + match[0].length)
const continuation = new RegExp(`^\\s*(/|、|至|到|–|—)\\s*(?:${requirement}-)?AC(\\d+)\\b`, 'u')
for (let next = tail.match(continuation); next; next = tail.match(continuation)) {
const end = Number(next[2])
if (['至', '到', '–', '—'].includes(next[1])) {
if (end < last || end - last > 1000) throw new Error(`无效验收范围:${match[0]}${next[0]}`)
for (let number = last; number <= end; number++)
references.add(`${requirement}-AC${number}`)
} else {
references.add(`${requirement}-AC${end}`)
}
last = end
tail = tail.slice(next[0].length)
}
}
return references
}
const walk = async (directory) => {
const files = []
for (const entry of await readdir(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) files.push(...(await walk(entryPath)))
else if (entry.isFile() && entry.name.endsWith('.test.ts')) files.push(entryPath)
}
return files
}
const stringConstants = (source) =>
new Map(
[...source.matchAll(/\bconst\s+([A-Z][A-Z0-9_]*)\s*=\s*['"]([^'"]+)['"]/gu)].map((match) => [
match[1],
match[2]
])
)
const fixturePathConstants = (source) =>
new Set(
[...source.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=[^;]*tests\/fixtures[^;]*;/gu)].map(
(match) => match[1]
)
)
const resolveString = (raw, constants) => {
const value = raw.trim()
const quoted = value.match(/^['"]([^'"]+)['"]$/u)
return quoted?.[1] || constants.get(value) || null
}
const fileCaseIds = (filePath, source, constants) => {
const explicit = [...source.matchAll(/\bcaseId\s*:\s*([^,\n}]+)/gu)]
.map((match) => resolveString(match[1], constants))
.filter(Boolean)
if (explicit.length > 0) return [...new Set(explicit)]
return [path.basename(filePath).replace(/\.e2e\.test\.ts$|\.test\.ts$/u, '')]
}
const planCases = async (planName) => {
const source = await readFile(path.join(PLAN_ROOT, planName), 'utf8')
const headings = [...source.matchAll(/^#{4,5} `([^`]+)`\s*$/gmu)]
return {
source,
cases: headings.map((heading, index) => ({
id: heading[1],
body: source
.slice(heading.index, headings[index + 1]?.index ?? source.length)
.split(/\n#{1,3} /u)[0]
}))
}
}
const failures = []
const summaries = []
for (const [domain, rule] of Object.entries(DOMAIN_RULES)) {
const domainRoot = path.join(CASE_ROOT, domain)
const files = await walk(domainRoot)
const actualCaseIds = []
for (const filePath of files) {
const source = await readFile(filePath, 'utf8')
const relativePath = path.relative(ROOT, filePath)
const relativeParts = path.relative(domainRoot, filePath).split(path.sep)
const expectedSuite = relativeParts.length > 1 ? relativeParts[0] : null
const isE2E = filePath.endsWith('.e2e.test.ts')
const constants = stringConstants(source)
const caseIds = fileCaseIds(filePath, source, constants)
actualCaseIds.push(...caseIds)
if (rule.type === 'e2e' && !isE2E) failures.push(`${relativePath}: ${domain} 只允许 E2E 测试`)
if (rule.type === 'backend' && isE2E)
failures.push(`${relativePath}: ${domain} 只允许 backend 测试`)
for (const match of source.matchAll(/\bknowledge\s*:\s*['"]([^'"]+)['"]/gu)) {
if (!['none', 'cached', 'cold'].includes(match[1]))
failures.push(`${relativePath}: unsupported knowledge policy ${match[1]}`)
}
if (isE2E) {
const executionValues = [...source.matchAll(/\bexecution\s*:\s*['"]([^'"]+)['"]/gu)].map(
(match) => match[1]
)
for (const execution of executionValues) {
if (!['normal', 'live', 'exclusive'].includes(execution)) {
failures.push(`${relativePath}: unsupported E2E execution class ${execution}`)
}
}
if (new Set(executionValues).size > 1) {
failures.push(`${relativePath}: one E2E file cannot mix execution classes`)
}
}
if (
domain === 'unit' &&
(source.includes('src/renderer') || /@vitest-environment\s+jsdom/u.test(source))
) {
failures.push(`${relativePath}: Unit 不允许 Renderer 或 jsdom`)
}
if (domain === 'unit' && /\bcreate[A-Za-z0-9]+ToolRuntime\b/u.test(source)) {
failures.push(
`${relativePath}: 完整生产工具 runtime 流程必须放到 ai/tool,不允许由 Unit 直接拥有`
)
}
const fixtureConstants = fixturePathConstants(source)
const writeTargetArgIndex = {
writeFile: 0,
appendFile: 0,
writeFileSync: 0,
appendFileSync: 0,
mkdir: 0,
mkdirSync: 0,
rm: 0,
rmSync: 0,
unlink: 0,
unlinkSync: 0,
copyFile: 1,
copyFileSync: 1,
cp: 1,
rename: 1,
renameSync: 1
}
const writeOpPattern = new RegExp(
`\\b(${Object.keys(writeTargetArgIndex).join('|')})\\s*\\(`,
'gu'
)
const extractArgs = (sourceText, argsStart) => {
const args = []
let depth = 1
let current = ''
let inString = null
for (let index = argsStart; index < sourceText.length && depth > 0; index += 1) {
const char = sourceText[index]
if (inString) {
current += char
if (char === inString && sourceText[index - 1] !== '\\') inString = null
} else if (char === '"' || char === "'" || char === '`') {
inString = char
current += char
} else if (char === '(') {
depth += 1
current += char
} else if (char === ')') {
depth -= 1
if (depth === 0) {
args.push(current)
break
}
current += char
} else if (char === ',' && depth === 1) {
args.push(current)
current = ''
} else {
current += char
}
}
return args
}
for (const match of source.matchAll(writeOpPattern)) {
const targetIndex = writeTargetArgIndex[match[1]]
const targetArg = extractArgs(source, match.index + match[0].length)[targetIndex] ?? ''
const targetsFixture =
targetArg.includes('tests/fixtures') ||
/path\.(?:resolve|join)\(\s*['"`](?:[^'"`,)]*,\s*)?fixtures['"`]/u.test(targetArg) ||
[...fixtureConstants].some((name) => new RegExp(`\\b${name}\\b`, 'u').test(targetArg))
if (targetsFixture && !/\bgetFixtureWorkspacePath\b/u.test(targetArg)) {
failures.push(
`${relativePath}: 禁止向 tests/fixtures/** 写入或修改(fixture 只读,需要写入时复制到隔离 workspace):${targetArg.slice(0, 100)}`
)
}
}
const isAiLive = relativePath.split(path.sep).join('/').includes('tests/case/ai/human/live/')
if ((domain === 'ai' || domain === 'knowledge') && /\bvi\.(?:fn|mock|spyOn)\b/u.test(source)) {
failures.push(
`${relativePath}: 正式 backend 禁止 vi.fn / vi.mock / vi.spyOn,假对象只属于 unit`
)
}
if (isAiLive) {
const prohibited = [
['skipped live result', /\bskipped\s*:/u],
['live config probe', /\bhasLiveLlmConfigInEnv\b/u],
[
'LLM scripted override',
/\bstructuredOutputScript\b|\bllmScript\b|\bchatStructuredOverride\b/u
],
['case score gate', /\bscoreParts\b|\bruntime\.assertThat\(\s*score\b/u]
]
for (const [label, pattern] of prohibited) {
if (pattern.test(source)) failures.push(`${relativePath}: live 不允许 ${label}`)
}
if (!/\bloadLiveLlmConfigFromEnv\b|\brunBackendCase\b/u.test(source)) {
failures.push(`${relativePath}: live 必须走严格 env runner`)
}
}
if (domain !== 'unit') {
const declaredDomains = [...source.matchAll(/\bdomain\s*:\s*['"]([^'"]+)['"]/gu)].map(
(match) => match[1]
)
for (const declaredDomain of declaredDomains) {
if (!(declaredDomain in DOMAIN_RULES)) {
failures.push(`${relativePath}: domain ${declaredDomain} 不在允许列表中`)
} else if (declaredDomain !== domain) {
failures.push(`${relativePath}: domain ${declaredDomain} 与目录 ${domain} 不一致`)
}
}
const declaredSuites = [...source.matchAll(/\bsuite\s*:\s*['"]([^'"]+)['"]/gu)].map(
(match) => match[1]
)
if (expectedSuite) {
for (const suite of declaredSuites) {
if (suite !== expectedSuite) {
failures.push(`${relativePath}: suite ${suite} 与目录 ${expectedSuite} 不一致`)
}
}
}
const itMatches = [...source.matchAll(/\bit\s*\(\s*([^,\n]+)/gu)]
const itCaseIds = itMatches.map((match) => resolveString(match[1], constants)).filter(Boolean)
const hasDynamicIt = itMatches.length > itCaseIds.length
for (const itCaseId of itCaseIds) {
if (!caseIds.includes(itCaseId)) {
failures.push(`${relativePath}: 外层 it 名称 ${itCaseId} 没有对应 caseId`)
}
}
for (const caseId of caseIds) {
if (!itCaseIds.includes(caseId) && !hasDynamicIt) {
failures.push(`${relativePath}: caseId ${caseId} 与外层 it 名称不一致`)
}
}
const runnerName = rule.type === 'e2e' ? 'runE2ECase' : 'runBackendCase'
const runnerCount = [
...source.matchAll(new RegExp(`\\b${runnerName}(?:<[^;{}]+>)?\\s*\\(`, 'gu'))
].length
if (runnerCount < itMatches.length) {
failures.push(
`${relativePath}: ${itMatches.length} 个 it 只找到 ${runnerCount} 个 ${runnerName}`
)
}
}
}
if (rule.plan) {
const duplicates = actualCaseIds.filter(
(caseId, index, values) => values.indexOf(caseId) !== index
)
for (const caseId of new Set(duplicates)) failures.push(`${domain}: 重复 caseId ${caseId}`)
const plan = await planCases(rule.plan)
const plannedCaseIds = plan.cases.map((item) => item.id)
const planned = new Set(plannedCaseIds)
const actual = new Set(actualCaseIds)
for (const item of plan.cases) {
for (const field of PLAN_FIELDS) {
if (!new RegExp(`^- ${field}:`, 'mu').test(item.body)) {
failures.push(`${rule.plan}: case ${item.id} 缺少 ${field}`)
}
}
}
for (const caseId of planned) {
if (!actual.has(caseId)) failures.push(`${rule.plan}: case ${caseId} 没有实际测试`)
}
for (const caseId of actual) {
if (!planned.has(caseId)) failures.push(`${domain}: case ${caseId} 未登记到 ${rule.plan}`)
}
if (rule.prd) {
const prd = await readFile(path.join(PRD_ROOT, rule.prd), 'utf8')
const prdAcs = acceptanceReferences(prd, rule.acPrefixes)
const planAcs = acceptanceReferences(plan.source, rule.acPrefixes)
const registrationSource = [
...plan.cases.flatMap((item) => item.body.match(/^- Oracle:.*$/gmu) || []),
...[
...plan.source.matchAll(
/^## [^\n]*(?:自动化覆盖边界|发布前真实检查)[^\n]*\n([\s\S]*?)(?=^## |$(?![\s\S]))/gmu
)
].map((match) => match[1])
].join('\n')
const registeredAcs = acceptanceReferences(registrationSource, rule.acPrefixes)
for (const ac of prdAcs) {
if (!registeredAcs.has(ac)) {
failures.push(`${rule.plan}: 未登记验收 ${ac}(需具体 Oracle 或显式覆盖边界)`)
}
}
for (const ac of planAcs) {
if (!prdAcs.has(ac)) failures.push(`${rule.plan}: 引用了不存在的 ${ac}`)
}
}
summaries.push(`${domain} ${String(files.length)} files / ${String(actual.size)} cases`)
} else {
summaries.push(`${domain} ${String(files.length)} files`)
}
}
if (failures.length > 0) {
throw new Error(`测试架构校验失败:\n${failures.map((item) => `- ${item}`).join('\n')}`)
}
process.stdout.write(`[test-architecture] ${summaries.join(', ')}\n`)