benchmark.md 18.2 KB

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.md
    • repo_root/skills/skill-1/scripts/bar.py
    • repo_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;页面同时可见两个独立面板:
    1. "Skill 内容"面板:<pre class="code"> 元素内是该 Skill SKILL.md 的完整原始文本(可 用浏览器"查看源代码"/document.getElementById("skill_content") 核对文本与磁盘文件字节 一致,包括换行)。
    2. "迭代记录"面板:显示"该 Skill 尚无自主迭代记录"(因为 skill_iteration 表为空,这是 PRD 背景中确认为正确的既有行为,不应被本次改动改变)。
  • 通过标准:两个面板同时非空/各自呈现预期内容,其中一个面板的渲染逻辑失败不影响另一个( 可通过临时让 /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 复核)。