Files
Toolbox/dev_test_scripts/unit/test_travel_citytour_isolation.js
T

426 lines
20 KiB
JavaScript
Raw 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.
/* ============================================================
* test_travel_citytour_isolation.js —— 「游山玩水」skill 全栈隔离校验
*
* 为什么需要本脚本:
* 本 lab 的架构约定是「skill 全栈隔离」——每个 skill 在 skills/<skill_id>/ 下自带一整套
* 后端(config/index/ai/validate/repair + 技能包)与一整套前端(页面/渲染器/样式/导出器/
* ⬆图/照片),彼此零耦合。这个约定光靠注释和口头说明必然会被后续开发侵蚀(有人图省事
* 直接 require 兄弟 skill 或引用 lab 层业务文件),所以这里把它变成「失败即挡」的硬校验。
*
* 自动覆盖未来新增的 skill:
* 本脚本不写死任何 skill_id,而是通过 lab 的 registry 枚举当前所有 skill,
* 逐个断言。所以以后每加一个 skill,这套校验自动生效,无需改本文件。
*
* 校验项(对每个 skill):
* A. 后端:skill 目录内所有 .js 不得 require 出本目录(禁止 '../')
* B. 前端:skill 目录内所有 .html/.js/.css 的相对引用,解析后必须落在
* 「本 skill 目录内」或「lab 级 assets/vendor/」;其余一律违规
* (即:禁止引用兄弟 skill、禁止引用 lab 层业务文件/配置/照片)
* C. 前端:不得通过绝对路径引用 lab 业务文件(只允许 /api/... 接口前缀)
* D. 契约:config.json / index.js(导出 bindRoutes)/ index.html 三者齐全且可加载
* E. 运行时:后端能 require、bindRoutes 能注册出预期路由、prompt 能组装、校验器不抛异常
*
* 运行:node dev_test_scripts/unit/test_travel_citytour_isolation.js
* 退出码:0 = 全部通过;1 = 存在违规
* ============================================================ */
const fs = require('fs')
const path = require('path')
const ROOT = process.cwd()
const LAB_DIR = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', 'travel_citytour')
const LAB_PUBLIC = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour')
const BE_SKILLS = path.join(LAB_DIR, 'skills')
const FE_SKILLS = path.join(LAB_PUBLIC, 'skills')
// 唯一允许 skill 向上引用的位置:lab 级第三方库(无业务语义)
const ALLOWED_UP_REF_PREFIX = path.join(LAB_PUBLIC, 'assets', 'vendor')
const bad = []
const info = []
const registry = require(path.join(LAB_DIR, 'index.js')).constructor ? require(path.join(LAB_DIR, 'registry.js')) : require(path.join(LAB_DIR, 'registry.js'))
const { makeCtx } = require(path.join(LAB_DIR, 'index.js'))
const rel = (p) => path.relative(ROOT, p)
/* 递归收集目录下的文件(跳过 data / node_modules / .gitkeep) */
const walkFiles = (dir, exts, out, skipDirs) => {
out = out || []
skipDirs = skipDirs || ['data', 'node_modules', '.git']
if (!fs.existsSync(dir)) return out
fs.readdirSync(dir, { withFileTypes: true }).forEach((e) => {
if (e.isDirectory()) {
if (skipDirs.indexOf(e.name) >= 0) return
walkFiles(path.join(dir, e.name), exts, out, skipDirs)
return
}
if (exts.indexOf(path.extname(e.name)) >= 0) out.push(path.join(dir, e.name))
})
return out
}
/* ---------- A. 后端:禁止 require 出本 skill 目录 ---------- */
const checkBackendIsolation = (skillId) => {
const dir = path.join(BE_SKILLS, skillId)
walkFiles(dir, ['.js']).forEach((file) => {
const txt = fs.readFileSync(file, 'utf-8')
// 去掉注释行,避免把「隔离说明」里提到的 ../ 误判成真实引用
const code = txt.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
const m = code.match(/require\(\s*['"]\.\.?\/(\.\.\/)*[^'"]+['"]\s*\)/g) || []
m.forEach((hit) => {
if (/require\(\s*['"]\.\//.test(hit)) return // './xxx' 同目录,允许
bad.push(`[后端隔离] ${rel(file)} 出现向上越级 require:${hit}(skill 后端只能 require 本目录文件或 npm 包)`)
})
})
}
/* ---------- B/C. 前端:相对引用与绝对引用必须落在允许范围 ---------- */
const REF_RE = /(?:src|href)\s*=\s*["']([^"']+)["']|fetch\(\s*['"]([^'"]+)['"]/g
// 两条【契约要求】的导航例外(不是业务耦合,必须放行):
// 1. 站点首页 '/' —— 401 引导面板里的「重新从首页进入」按钮,指到工具箱根
// 2. lab 聚合页 <lab>/index.html —— 每个 skill 页面都要求有「← 返回技能列表」
const LAB_AGG_PAGE = path.join(LAB_PUBLIC, 'index.html')
const checkFrontendIsolation = (skillId) => {
const skillRoot = path.join(FE_SKILLS, skillId)
walkFiles(skillRoot, ['.html', '.js', '.css'], null, ['node_modules', '.git']).forEach((file) => {
const txt = fs.readFileSync(file, 'utf-8')
REF_RE.lastIndex = 0
let hit
while ((hit = REF_RE.exec(txt)) !== null) {
const ref = String(hit[1] || hit[2] || '').trim()
if (!ref) continue
if (/^(https?:)?\/\//.test(ref) || ref.indexOf('data:') === 0) continue // 外链/内联,放过
if (ref === '/') continue // 例外 1:站点首页导航
if (ref.charAt(0) === '/') {
// 绝对路径:只允许接口前缀,不允许指向 lab 业务文件
if (ref.indexOf('/api/') !== 0) {
bad.push(`[前端隔离] ${rel(file)} 出现绝对路径引用:${ref}(只允许 /api/... 接口前缀)`)
}
continue
}
if (ref.charAt(0) === '#') continue
// 相对路径:解析后必须落在「本 skill 目录内」或「lab 级 assets/vendor/」
const resolved = path.resolve(path.dirname(file), ref.split('?')[0].split('#')[0])
if (resolved === LAB_AGG_PAGE) continue // 例外 2:返回聚合页
const insideSkill = resolved === skillRoot || resolved.indexOf(skillRoot + path.sep) === 0
const insideVendor = resolved === ALLOWED_UP_REF_PREFIX || resolved.indexOf(ALLOWED_UP_REF_PREFIX + path.sep) === 0
if (!insideSkill && !insideVendor) {
// 同 lab 但落在其他位置(兄弟 skill / lab 业务文件 / lab 配置)→ 违规
if (resolved.indexOf(LAB_PUBLIC + path.sep) === 0 || resolved.indexOf(LAB_DIR + path.sep) === 0) {
bad.push(`[前端隔离] ${rel(file)} 引用越界:${ref} → ${rel(resolved)}`
+ `(只允许本 skill 目录内,或 lab 级 ${rel(ALLOWED_UP_REF_PREFIX)}/)`)
}
// 完全跳出 lab 目录的(指向其他 tool / lab)也算违规
else if (resolved.indexOf(path.join(ROOT, 'public', 'tools') + path.sep) === 0
|| resolved.indexOf(path.join(ROOT, 'src') + path.sep) === 0) {
bad.push(`[前端隔离] ${rel(file)} 引用越界:${ref} → ${rel(resolved)}(禁止跨 tool / lab 引用)`)
}
}
}
})
}
/* ---------- 与兄弟 skill 的交叉引用(代码层面,注释不算) ---------- */
const checkSiblingRefs = (skillId, allIds) => {
const siblings = allIds.filter((x) => x !== skillId)
if (!siblings.length) return
const scan = (dir, exts) => walkFiles(dir, exts).forEach((file) => {
const code = fs.readFileSync(file, 'utf-8')
.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*|<!--)/.test(l)).join('\n')
siblings.forEach((sib) => {
// 只查「像引用」的用法(路径/字符串字面量),不看注释
const re = new RegExp('[\'"`/]' + sib + '[/\'"`]', 'g')
if (re.test(code)) {
bad.push(`[兄弟隔离] ${rel(file)} 代码中引用了兄弟 skill「${sib}」(skill 之间不得互相引用)`)
}
})
})
scan(path.join(BE_SKILLS, skillId), ['.js', '.json'])
scan(path.join(FE_SKILLS, skillId), ['.js', '.html', '.json'])
}
/* ---------- D/E. 契约与运行时 ---------- */
const checkContractAndRuntime = (skillId) => {
const beDir = path.join(BE_SKILLS, skillId)
const feDir = path.join(FE_SKILLS, skillId)
const need = [
[path.join(beDir, 'config.json'), '后端 config.json'],
[path.join(beDir, 'index.js'), '后端 index.js'],
[path.join(feDir, 'index.html'), '前端 index.html']
]
need.forEach(([p, label]) => {
if (!fs.existsSync(p)) bad.push(`[契约] ${skillId} 缺 ${label}`)
})
if (bad.some((b) => b.indexOf('[契约] ' + skillId) === 0)) return
const cfg = JSON.parse(fs.readFileSync(path.join(beDir, 'config.json'), 'utf-8'))
if (String(cfg.id || '') !== skillId) bad.push(`[契约] ${skillId} 的 config.json id 与目录名不一致:${cfg.id}`)
try {
const mod = require(path.join(beDir, 'index.js'))
if (typeof mod.bindRoutes !== 'function') {
bad.push(`[契约] ${skillId} 的 index.js 未导出 bindRoutes`)
return
}
// 后端不得自行实现鉴权(红线:鉴权必须由 lab 层通过 Router 注入)
const code = fs.readFileSync(path.join(beDir, 'index.js'), 'utf-8')
.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
;['nav_gate', 'parseCookie', 'verifyJwt', 'guidValue', 'DASHSCOPE_API_KEY'].forEach((k) => {
if (code.indexOf(k) >= 0) {
bad.push(`[红线] ${skillId}/index.js 出现「${k}」——鉴权/凭据必须由 lab 层 ctx 提供,skill 不得自行实现`)
}
})
const routes = []
const fakeRouter = { get: (p) => routes.push('GET ' + p), post: (p) => routes.push('POST ' + p), use: () => {} }
mod.bindRoutes(fakeRouter, makeCtx(skillId))
if (!routes.length) bad.push(`[契约] ${skillId} 的 bindRoutes 未注册任何路由`)
info.push(`${skillId}:后端路由 ${routes.join(' / ')}`)
} catch (e) {
bad.push(`[契约] ${skillId} 后端加载失败:${String(e.message || e)}`)
}
// 深度检查(可选):ai.js / validate.js 不是 lab 强制契约,只是两个已有 skill 的
// 约定俗成的拆分方式。新 skill 完全可以不拆(把逻辑都写在 index.js 里)。
// 所以这里只在「文件存在」时验证其可用性;不存在则跳过,避免把约定当强制而误报。
const hasAi = fs.existsSync(path.join(beDir, 'ai.js'))
const hasValidate = fs.existsSync(path.join(beDir, 'validate.js'))
if (!hasAi && !hasValidate) {
info.push(`${skillId}:未拆分 ai.js/validate.js(允许;lab 强制契约只有 config.json + index.js + index.html)`)
return
}
try {
const mod = require(path.join(beDir, 'index.js'))
if (hasAi) {
const aiMod = require(path.join(beDir, 'ai.js'))
if (typeof mod.loadBundle === 'function' && typeof aiMod.buildMessages === 'function') {
const b = mod.loadBundle()
const msgs = aiMod.buildMessages(b, { destination: '测试地' }, null)
if (!msgs || msgs.length !== 2) bad.push(`[运行时] ${skillId} buildMessages 未产出 system+user 两条消息`)
info.push(`${skillId}:prompt ${msgs[0].content.length} 字符`)
}
}
if (hasValidate) {
const { validatePayload } = require(path.join(beDir, 'validate.js'))
if (typeof mod.loadBundle === 'function' && typeof validatePayload === 'function') {
const chk = validatePayload({}, mod.loadBundle())
if (!chk.errors.length) bad.push(`[运行时] ${skillId} 校验器对空 payload 未报错(校验形同虚设)`)
info.push(`${skillId}:空 payload 报 ${chk.errors.length} 个 error`)
}
}
if (fs.existsSync(path.join(beDir, 'repair.js'))) require(path.join(beDir, 'repair.js'))
} catch (e) {
bad.push(`[运行时] ${skillId} 引擎加载失败:${String(e.message || e)}`)
}
}
/* ---------- F. 离线样例(若存在)必须通过本 skill 自己的校验器 ---------- */
const checkSamples = (skillId) => {
const beDir = path.join(BE_SKILLS, skillId)
const sampleDir = path.join(FE_SKILLS, skillId, 'sample')
if (!fs.existsSync(sampleDir)) return
const files = fs.readdirSync(sampleDir).filter((f) => f.endsWith('.json') && f !== 'report_spec.json')
if (!files.length) return
// 样例校验依赖本 skill 的 validate.js(可选拆分);没拆就跳过,不视为错误
if (!fs.existsSync(path.join(beDir, 'validate.js'))) {
info.push(`${skillId}:有样例但未拆分 validate.js,跳过样例校验`)
return
}
try {
const mod = require(path.join(beDir, 'index.js'))
const { validatePayload } = require(path.join(beDir, 'validate.js'))
const b = mod.loadBundle()
files.forEach((f) => {
const payload = JSON.parse(fs.readFileSync(path.join(sampleDir, f), 'utf-8'))
const chk = validatePayload(payload, b)
if (chk.errors.length) {
bad.push(`[样例] ${skillId}/sample/${f} 未通过本 skill 校验器:${chk.errors.slice(0, 4).join(' / ')}`)
} else {
info.push(`${skillId}:样例 ${f} 校验通过(${chk.warnings.length} warnings,` +
`卡片 ${chk.stats.cards} / 点位 ${chk.stats.markers}` +
(chk.stats.addressCovered === undefined ? '' : ` / 地址覆盖 ${chk.stats.addressCovered}`) + ')')
}
})
} catch (e) {
bad.push(`[样例] ${skillId} 样例校验失败:${String(e.message || e)}`)
}
}
/* ---------- G. destinations.json 的 sample_ref 必须相对【本 skill 前端目录】可解析 ----------
这是隔离后修正过的口径:以前 sample_ref 指向 lab 级共享目录,会让 A skill 的样例被 B skill 引用。 */
const checkSampleRefs = (skillId) => {
const beDest = path.join(BE_SKILLS, skillId, 'destinations.json')
if (!fs.existsSync(beDest)) return
const feSkillDir = path.join(FE_SKILLS, skillId)
try {
const dest = JSON.parse(fs.readFileSync(beDest, 'utf-8'))
;(dest.destinations || []).forEach((d) => {
;((d && d.runs) || []).forEach((r) => {
if (!r || !r.sample_ref) return
const resolved = path.join(feSkillDir, r.sample_ref)
if (!fs.existsSync(resolved)) {
bad.push(`[样例] ${skillId} destinations[${d.key}].runs.sample_ref 不存在(应相对本 skill 前端目录):${r.sample_ref}`)
} else if (resolved.indexOf(feSkillDir + path.sep) !== 0) {
bad.push(`[样例] ${skillId} destinations[${d.key}].sample_ref 越界到本 skill 目录之外:${r.sample_ref}`)
}
})
})
} catch (e) {
bad.push(`[样例] ${skillId} destinations.json 解析失败:${String(e.message || e)}`)
}
}
/* ---------- H. 生成结果「有结构问题也必须落盘 + 200 回传」 ----------
*
* 背景(真实事故,本 skill 踩过):过去只要结构校验出现哪怕 1 个 error,服务端就整份丢弃、
* 不落盘也不回传 payload,用户什么都拿不到。而那个 error 很可能只是校验器的误判 ——
* 一次 4 分钟 + 上万 token 的生成就这样白烧了。
*
* 这条检查直接调用 skill 注册出来的 POST /generate(把 ctx.callLLM 换成桩,返回一份必然
* 不合契约的空 payload),断言三件事:
* 1) 结果仍被写入 skill 私有 data/last_result.json
* 2) HTTPstatus 为 200(若为 422,前端会走失败分支,payload 反而渲染不出来)
* 3) envelope.ok === false(如实告知有结构问题,不假装成功)
*
* 自动覆盖所有「拆了 ai.js」的 skill;未拆分的(如脚手架骨架)跳过,不算违规。
* ---------- */
const os = require('os')
const checkPersistOnError = (skillId) => {
const beDir = path.join(BE_SKILLS, skillId)
if (!fs.existsSync(path.join(beDir, 'ai.js'))) {
info.push(`[落盘] ${skillId}:未拆分 ai.js,跳过本项检查`)
return null
}
let mod = null
let bundle = null
let answers = {}
try {
mod = require(path.join(beDir, 'index.js'))
bundle = mod.loadBundle()
// 按本 skill 自己的 qa.json 组装必填答案(不写死任何字段名)
const qa = bundle.qa || {}
if ((qa.destination_input || {}).required !== false) answers.destination = '测试地'
;(qa.questions || []).forEach((q) => {
if (!q || q.required === false || !q.key) return
answers[q.key] = (q.default !== undefined && q.default !== null && q.default !== '')
? q.default
: (q.type === 'number' ? 1 : '测试值')
})
} catch (e) {
bad.push(`[落盘] ${skillId} 准备失败:${String((e && e.message) || e)}`)
return null
}
let tmpDir = ''
try {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tclab-last-'))
} catch (e) {
bad.push(`[落盘] ${skillId} 无法创建临时目录:${String((e && e.message) || e)}`)
return null
}
const ctx = makeCtx(skillId)
ctx.dataDir = tmpDir
// 桩掉模型调用:返回空对象 → 必然触发一堆结构 error
ctx.callLLM = async () => ({ content: JSON.stringify({}), finishReason: 'stop', usage: {} })
const handlers = {}
const fakeRouter = {
get: (p, ...mw) => { handlers['GET ' + p] = mw[mw.length - 1] },
post: (p, ...mw) => { handlers['POST ' + p] = mw[mw.length - 1] },
use: () => {}
}
let gen = null
try {
mod.bindRoutes(fakeRouter, ctx)
gen = handlers['POST /generate']
} catch (e) {
bad.push(`[落盘] ${skillId} 注册路由失败:${String((e && e.message) || e)}`)
return null
}
if (typeof gen !== 'function') {
bad.push(`[落盘] ${skillId} 未注册 POST /generate`)
return null
}
const res = {
statusCode: 200,
body: null,
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
set() { return this },
on() { return this },
writableEnded: true
}
return Promise.resolve()
.then(() => gen({ body: { answers }, headers: {} }, res))
.then(() => {
const saved = path.join(tmpDir, 'last_result.json')
if (!fs.existsSync(saved)) {
bad.push(`[落盘] ${skillId} 结果有结构问题时未落盘 —— 用户会失去整份生成结果(该行为必须避免)`)
return
}
let env = null
try {
env = JSON.parse(fs.readFileSync(saved, 'utf-8'))
} catch (e) {
bad.push(`[落盘] ${skillId} 落盘文件不是合法 JSON`)
return
}
const nErr = ((env.check || {}).errors || []).length
if (nErr === 0) {
bad.push(`[落盘] ${skillId} 用例失效:预期产生结构 error,实际为 0`)
return
}
if (res.statusCode !== 200) {
bad.push(`[落盘] ${skillId} 有 error 时返回 ${res.statusCode}(应为 200;422 会让前端渲染不出 payload)`)
}
if (!res.body || res.body.ok !== false) {
bad.push(`[落盘] ${skillId} 有 error 时 envelope.ok 应为 false(如实告知结构问题)`)
}
info.push(`[落盘] ${skillId}:${nErr} 处结构问题时仍落盘 + 200 回传 + ok=false(用户可查看/导出)`)
})
.catch((e) => {
bad.push(`[落盘] ${skillId} 「有 error 也落盘」测试异常:${String((e && e.message) || e)}`)
})
.then(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch {}
})
}
/* ---------- 主流程 ---------- */
const health = registry.health()
if (!health.ok) bad.push('[registry] 自检未通过:' + JSON.stringify(health.problems_detail))
const ids = health.items.map((i) => i.id)
if (!ids.length) bad.push('[registry] 未扫描到任何 skill')
ids.forEach((id) => {
checkBackendIsolation(id)
checkFrontendIsolation(id)
checkContractAndRuntime(id)
checkSamples(id)
checkSampleRefs(id)
})
if (ids.length > 1) ids.forEach((id) => checkSiblingRefs(id, ids))
const report = () => {
console.log('===== 游山玩水 · skill 全栈隔离校验 =====')
info.push(`扫描到 ${ids.length} 个 skill:${ids.join(', ')}`)
info.push('隔离口径:skill 内联引用只允许「本 skill 目录内」或「lab 级 assets/vendor/」;后端只允许 require 本目录或 npm 包')
info.forEach((l) => console.log('· ' + l))
if (bad.length) {
console.log('\n发现 ' + bad.length + ' 个违规:')
bad.forEach((b) => console.log(' ✗ ' + b))
process.exit(1)
}
console.log('\n✓ 隔离校验全部通过(' + ids.length + ' 个 skill)')
}
Promise.all(ids.map((id) => checkPersistOnError(id)).filter(Boolean)).then(report)