Benchmark:Skill 详情页展示真实内容(v_skill_audit_log 契约收口)
对应 SDD:《SDD:Skill 详情页展示真实内容(v_skill_audit_log 契约收口)》,Unit DU-01。
本文档定义 DU-01 的可验证验收场景,覆盖三层:src/skill_query.py::get_skill_content()
单元层、src/backend_server.py HTTP 路由层、frontend/app.js 手工验证层,以及既有测试的回归
范围。每个场景给出具体输入、预期行为、可观察的通过标准(返回值/状态码/DOM 内容/异常类
型),不是场景标题的罗列。
0. 测试环境与前置数据约定
所有单元层 / 路由层场景假定以下共享 fixture(除非场景自身另行说明覆盖值):
- 临时目录
repo_root/,其下repo_root/skills/skill-1/SKILL.md,内容为已知字符串"# Skill One\n\nBody text.\n"。 - SQLite
skill表插入一行:
skill_id='skill-1', owner_org_node='coder', file_path='skills/skill-1',
current_git_commit_sha='unknown', source='migrated_from_hermes',
migrated_from_profile='code', status='active'
skill_iteration表为空(对应 PRD 背景中"当前没有迭代记录"这句话本身正确的前提)。
每个场景会在此基线上做局部覆盖(如删除文件、篡改 file_path、增加支持文件等),并在场景描
述中明确写出与基线的差异。
1. 单元层场景:src/skill_query.py::get_skill_content()
U1(happy path)正常内容读取,无支持文件
- 输入:基线 fixture,调用
get_skill_content(conn, "skill-1", repo_path=repo_root)。 - 预期行为:函数正常返回,不抛任何异常。
- 通过标准(逐字段断言):
python result = get_skill_content(conn, "skill-1", repo_path=repo_root) assert result == { "skill_id": "skill-1", "owner_org_node": "coder", "file_path": "skills/skill-1", "current_git_commit_sha": "unknown", "source": "migrated_from_hermes", "migrated_from_profile": "code", "status": "active", "content": "# Skill One\n\nBody text.\n", "supporting_files": [], } - 对应测试:
GetSkillContentTest.test_returns_real_file_content(重写)。
U2(happy path)支持文件枚举,多层子目录
- 输入:在基线基础上,额外创建:
repo_root/skills/skill-1/references/foo.mdrepo_root/skills/skill-1/scripts/bar.pyrepo_root/skills/skill-1/SKILL.md内容不变。
- 预期行为:
content字段仍是SKILL.md全文;supporting_files列出另外两个文件,使用 相对skill_dir的相对路径,且升序排序、不包含SKILL.md自身。 - 通过标准:
python result = get_skill_content(conn, "skill-1", repo_path=repo_root) assert result["content"] == "# Skill One\n\nBody text.\n" assert result["supporting_files"] == ["references/foo.md", "scripts/bar.py"] assert "SKILL.md" not in result["supporting_files"] - 对应测试:新增/重写
GetSkillContentTest.test_lists_supporting_files_sorted。
U3(boundary)目录下只有 SKILL.md,无其他文件
- 输入:基线 fixture(无额外文件)。
- 预期行为:
supporting_files == [](空列表,不是None,不是缺字段)。 - 通过标准:
isinstance(result["supporting_files"], list) and result["supporting_files"] == []。 - 对应测试:并入 U1 断言即可覆盖,无需单独用例。
U4(failure→非异常语义)SKILL.md 磁盘文件缺失,DB 行仍存在
- 输入:DB 中
skill-1行照常存在,但删除/从不创建repo_root/skills/skill-1/SKILL.md(repo_root/skills/skill-1/目录本身也不存在,或存在但 为空目录——两种子场景都要覆盖)。 - 预期行为:不抛出任何异常(不是
SkillContentMissingError,该类已被删除)。函数正常 返回一个 dict,content字段为None。 - 通过标准:
python result = get_skill_content(conn, "skill-1", repo_path=repo_root) assert result["content"] is None assert result["supporting_files"] == [] # 目录不存在时 os.walk 返回空,不报错 assert result["skill_id"] == "skill-1" # 其余字段仍从 DB 正常返回 - 对应测试:
GetSkillContentTest.test_missing_file_returns_none_content(重写,原断言assertRaises(SkillContentMissingError)改为断言content is None且不抛异常)。
U5(failure)skill_id 在 skill 表中不存在
- 输入:
get_skill_content(conn, "skill-does-not-exist", repo_path=repo_root)。 - 预期行为:抛出
SkillNotFoundError,且不做任何文件系统访问(DB 查询先于文件路径解 析)。 - 通过标准:
python with self.assertRaises(skill_query.SkillNotFoundError): get_skill_content(conn, "skill-does-not-exist", repo_path=repo_root) - 对应测试:既有
GetSkillContentTest.test_nonexistent_skill_raises_not_found(保留,行为 未变,仅确认契约变更没有波及此分支)。
U6(failure/security)路径穿越——file_path 含相对 ../ 跳出 skills/ 目录
- 输入:DB 中插入一行
skill_id='evil-1',file_path='skills/../../etc/passwd'(或等价的../相对路径写法,具体依_skill_file_path()的既有解析规则构造)。调用get_skill_content(conn, "evil-1", repo_path=repo_root)。 - 预期行为:抛出
SkillPathTraversalError(不是SkillContentMissingError——该类型已删 除)。在异常抛出前,函数从未调用open()——用哨兵验证:在越界目标路径预先放置一个内容 可识别的哨兵文件(例如 repo 外层一个sentinel.txt写入"SENTINEL-SHOULD-NEVER-BE-READ"), 断言异常抛出且该哨兵内容从未出现在任何返回值或日志中。 - 通过标准:
python with self.assertRaises(skill_query.SkillPathTraversalError): get_skill_content(conn, "evil-1", repo_path=repo_root) # 哨兵文件从未被打开:可通过 mock open() 断言 open 从未以哨兵路径为参数被调用, # 或通过文件系统 side-channel(哨兵文件 mtime/访问标记不变)间接验证 - 对应测试:
GetSkillContentTest.test_traversal_via_relative_dotdot_in_file_path(重写, 异常类型改为SkillPathTraversalError)。
U7(failure/security)路径穿越——file_path 通过目录组件字面量跳出(如 skills-evil/)
- 输入:DB 中插入一行
skill_id='evil-2',file_path='skills-evil/leak'(realpath前缀 匹配上容易因字符串前缀误判为在skills_root内,例如"...skills"是"...skills-evil"的 字符串前缀但不是路径前缀——用以验证用的是os.path.commonpath,不是朴素的str.startswith)。 - 预期行为:抛出
SkillPathTraversalError。 - 通过标准:
python with self.assertRaises(skill_query.SkillPathTraversalError): get_skill_content(conn, "evil-2", repo_path=repo_root) - 对应测试:
GetSkillContentTest.test_traversal_via_directory_component_returns_path_traversal_error(重写,函数名/断言从..._returns_content_missing改为反映新异常类型)。
U8(boundary)SkillContentMissingError 符号已被移除
- 输入:静态检查,非运行时用例。
- 预期行为:
src/skill_query.py中不再存在SkillContentMissingError类定义;src/backend_server.py中不再有引用该符号的except分支。 - 通过标准:
grep -rn SkillContentMissingError src/返回空结果。 - 验证方式:Code Review / grep,非单元测试断言(记录在 DoD 清单中)。
2. 路由层场景:GET /api/skills/:skill_id(SkillRouteTest)
共享前置:backend_server 测试基线起一个真实 HTTPServer 线程或使用既有测试基建(与
SkillRouteTest 现有其他用例一致的启动方式),DB fixture 同第 0 节。
R1(happy path)正常内容,200
- 请求:
GET /api/skills/skill-1(基线 fixture,含 U2 的两个支持文件)。 - 预期状态码:
200。 - 预期响应体(JSON,逐字段):
json { "skill_id": "skill-1", "owner_org_node": "coder", "file_path": "skills/skill-1", "current_git_commit_sha": "unknown", "source": "migrated_from_hermes", "migrated_from_profile": "code", "status": "active", "content": "# Skill One\n\nBody text.\n", "supporting_files": ["references/foo.md", "scripts/bar.py"] } - 通过标准:状态码等值比较 + 响应体逐 key 相等比较(不是子集匹配,字段名必须恰好是
supporting_files,不是support_files)。 - 对应测试:
SkillRouteTest.test_content_returns_real_content(重写,URL 从/api/skills/skill-1/content改为/api/skills/skill-1,字段名同步改)。
R2(boundary)磁盘文件缺失,200 + content: null
- 请求:
GET /api/skills/skill-1,DB 行存在但SKILL.md文件已删除(同 U4 场景)。 - 预期状态码:
200(不是 404——这是本次契约变更的核心行为差异之一)。 - 预期响应体:
json {"skill_id": "skill-1", "...": "...", "content": null, "supporting_files": []} - 通过标准:
resp.status == 200且json.loads(resp.body)["content"] is None。 - 对应测试:新增
SkillRouteTest.test_content_missing_file_returns_200_null(路由层此前 完全没有覆盖越界/缺失场景,仅skill_query单元层有——本用例是 SDD §6 明确要求新增,不是重 写)。
R3(failure)skill 不存在,404
- 请求:
GET /api/skills/does-not-exist。 - 预期状态码:
404。 - 预期响应体:
{"error": "NOT_FOUND"}。 - 通过标准:状态码 + body 精确匹配。
- 对应测试:
SkillRouteTest.test_content_nonexistent_skill_returns_404(重写,URL 改为裸skill_id)。
R4(failure/security)路径穿越,400
- 请求:
GET /api/skills/evil-1,DB 中该行file_path为越界值(同 U6 fixture)。 - 预期状态码:
400(不是 404,也不是 500)。 - 预期响应体:
{"error": "INVALID_SKILL_ID"}。 - 通过标准:状态码 + body 精确匹配;额外断言响应体中不含任何越界目标路径的文件内容片段。
- 对应测试:新增
SkillRouteTest.test_content_path_traversal_returns_400(路由层新增, SDD §6 明确要求)。
R5(boundary)路由匹配优先级——/audit 后缀不被裸 skill_id 分支误吞
- 请求:
GET /api/skills/skill-1/audit(既有的迭代审计接口)。 - 预期行为:命中既有
_handle_skill_audit_log,不命中新的裸skill_id分支(新分支要 求"/" not in skill_id,"skill-1/audit"含/,天然不满足,但需要显式测试验证路由顺序 没有被写反)。 - 预期状态码/响应体:与改动前完全一致——
skill_iteration表为空时返回200+[]。 - 通过标准:
resp.status == 200 and json.loads(resp.body) == []。 - 对应测试:既有
SkillRouteTest.test_audit_log_empty_returns_empty_list(原样保留,作为 路由顺序回归证据,不修改)。
R6(boundary)路由匹配优先级——/diff 端点不受影响
- 请求:
GET /api/skills/diff?...(既有 diff 接口的合法查询)。 - 预期行为:命中既有
_handle_skill_diff分支(在裸skill_id分支之前注册),行为与 改动前完全一致。 - 通过标准:既有
GetDiffTest/SkillRouteTest.test_diff_*全部保持通过,不需新增用例。
R7(failure)POST /api/skills/:skill_id/rollback 不受本次改动影响
- 请求:既有
test_rollback_*用例的请求不变。 - 预期行为:
do_POST分支未改动,行为完全一致。 - 通过标准:既有
SkillRouteTest.test_rollback_*全部保持通过,不需新增用例。
3. 前端手工验证场景(frontend/app.js,无自动化测试基建,与 PRD §8 一致)
环境:python3 -m src.backend_server --port 8420,浏览器打开
http://127.0.0.1:8420/#/skills。
F1(happy path)点击已迁移 Skill,两面板并列显示
- 操作:在
#/skills列表点击任一真实存在磁盘文件的 Skill(如迁移后的coder/planner等真实 skill_id)。 - 预期页面:跳转到
#/skills/:id/audit;页面同时可见两个独立面板:- "Skill 内容"面板:
<pre class="code">元素内是该 SkillSKILL.md的完整原始文本(可 用浏览器"查看源代码"/document.getElementById("skill_content")核对文本与磁盘文件字节 一致,包括换行)。 - "迭代记录"面板:显示"该 Skill 尚无自主迭代记录"(因为
skill_iteration表为空,这是 PRD 背景中确认为正确的既有行为,不应被本次改动改变)。
- "Skill 内容"面板:
- 通过标准:两个面板同时非空/各自呈现预期内容,其中一个面板的渲染逻辑失败不影响另一个(
可通过临时让
/audit请求失败——如断网模拟——验证"Skill 内容"面板仍正常显示)。
F2(boundary)无第三方渲染依赖
- 操作:打开浏览器开发者工具 Network 面板,刷新
#/skills/:id/audit页面。 - 预期行为:页面加载的脚本资源里不出现任何 Markdown 渲染库或代码高亮库(如
marked.js/highlight.js/prismjs等);"Skill 内容"面板的 DOM 元素是纯<pre>文本节 点,未被转换为渲染后的 HTML 结构(如<h1>/<code class="language-*">)。 - 通过标准:Network 面板资源列表 +
document.getElementById("skill_content").innerHTML中除转义后的原始 Markdown 文本外不含渲染产物标签。
F3(failure)磁盘文件缺失时的降级显示
- 操作:临时删除某个已迁移 Skill 目录下的
SKILL.md(测试环境操作,验证后恢复),刷新对 应详情页。 - 预期页面:"Skill 内容"面板显示文案"(内容文件不可用)",不抛 JS 异常,不出现空白面 板或浏览器控制台报错;"迭代记录"面板不受影响,正常渲染。
- 通过标准:浏览器控制台无未捕获异常;"Skill 内容"面板文本节点内容精确等于 "(内容文件不可用)"。
F4(failure)Skill 完全不存在时的整页错误态
- 操作:直接在地址栏输入
http://127.0.0.1:8420/#/skills/does-not-exist/audit。 - 预期页面:页面顶部出现统一错误横幅,文案含"该 Skill 不存在";"Skill 内容"面板与"迭 代记录"面板均不渲染任何内容(不是各自显示两条不一致的错误提示)。
- 通过标准:
#skill_audit_error元素display不为none且文本含"不存在";#skill_content、#skill_audit_list的innerHTML均为空字符串。
F5(boundary)支持文件清单展示
- 前置:为某个测试用 Skill 目录额外放置
references/example.md。 - 操作:打开该 Skill 详情页。
- 预期页面:"Skill 内容"面板在正文
<pre>之外(之前或之后,与现有 DOM 结构一致的位 置)显示一行提示,形如"支持文件:references/example.md";若无支持文件的 Skill 则显示"无 支持文件"。 - 通过标准:对应 hint 文本节点内容与磁盘实际支持文件列表一致;不存在支持文件的 Skill 显 示"无支持文件"而不是空白或报错。
4. 回归范围:既有测试必须保持通过,断言不得修改
以下测试类/方法不在本次契约变更范围内,运行 python3 -m unittest src.test_skill_query -v 与
python3 -m unittest src.test_backend_server.SkillRouteTest -v 时必须保持全绿,且其断言内容
(包括请求 URL、期望字段名、期望状态码)逐字不变:
src/test_skill_query.py:ListSkillsTest全部用例、GetSkillAuditLogTest全部用例、GetDiffTest全部用例。src/test_backend_server.py::SkillRouteTest:test_list_returns_seeded_skill、test_audit_log_*、test_diff_*、test_full_update_diff_rollback_cycle、test_rollback_*。
通过标准:git diff 中上述测试方法体不出现在改动范围内(除非是纯粹的、不改变断言语义的
重排/import 调整)。若 diff 触碰了这些方法的断言内容,视为违反 SDD §0 裁决,判定为不通过。
5. 全量回归
- 命令:
python3 -m unittest discover -s src。 - 通过标准:全部用例通过(0 failure,0 error),特别关注
src/test_agent_profile.py、src/test_skill_update.py等间接依赖skill_query/skill_update模块符号的测试文件不因SkillContentMissingError删除而出现ImportError/AttributeError。
6. 验收标准总表(PRD AC 逐条 → 本文档场景编号)
| PRD AC | 描述 | 覆盖场景 |
|---|---|---|
| AC1 | 真实内容以 <pre> 展示 |
F1、F2、R1 |
| AC2 | 变更审计面板行为不变 | R5、F1(迭代记录部分) |
| AC3 | 两面板并列、互不依赖 | F1、F3(局部失败不影响另一面板) |
| AC4 | 文件缺失显示"内容文件不可用",不 500 | U4、R2、F3 |
| AC5 | skill 不存在 → 整页错误态,两面板都不渲染,404 | U5、R3、F4 |
| AC6 | 越界 file_path 不读出目录外内容,400 非 500 |
U6、U7、R4 |
| AC7 | 既有测试全通过、断言不被无理由篡改 | 第 4 节回归范围 |
| AC8 | 前端无新第三方依赖 | F2 |
7. Definition of Done 检查清单(可勾选执行)
- [ ] U1–U8 全部通过(
GetSkillContentTest重写后的类)。 - [ ] R1–R7 全部通过(
SkillRouteTest重写 + 新增用例)。 - [ ]
grep -rn SkillContentMissingError src/输出为空。 - [ ]
python3 -m unittest src.test_skill_query -v全绿。 - [ ]
python3 -m unittest src.test_backend_server.SkillRouteTest -v全绿。 - [ ]
python3 -m unittest discover -s src全绿。 - [ ] F1–F5 手工验证全部通过(浏览器实测,非自动化)。
- [ ] 第 4 节列出的既有测试方法体逐字未改动(
git diff复核)。