Commit 86c5c15a2fb5801b188aec304b1980ebd74bf74f

Authored by graphlemon
1 parent 78be2e6f

graphlemon: benchmark artifact for task b243dfe8-4da8-49bd-895b-8b9e9ed17280

.graphlemon/runs/b243dfe8-4da8-49bd-895b-8b9e9ed17280/benchmark.md 0 → 100644
  1 +# Benchmark:Skill 详情页展示真实内容(`v_skill_audit_log` 契约收口)
  2 +
  3 +对应 SDD:《SDD:Skill 详情页展示真实内容(`v_skill_audit_log` 契约收口)》,Unit DU-01。
  4 +
  5 +本文档定义 DU-01 的可验证验收场景,覆盖三层:`src/skill_query.py::get_skill_content()`
  6 +单元层、`src/backend_server.py` HTTP 路由层、`frontend/app.js` 手工验证层,以及既有测试的回归
  7 +范围。每个场景给出**具体输入、预期行为、可观察的通过标准**(返回值/状态码/DOM 内容/异常类
  8 +型),不是场景标题的罗列。
  9 +
  10 +---
  11 +
  12 +## 0. 测试环境与前置数据约定
  13 +
  14 +所有单元层 / 路由层场景假定以下共享 fixture(除非场景自身另行说明覆盖值):
  15 +
  16 +- 临时目录 `repo_root/`,其下 `repo_root/skills/skill-1/SKILL.md`,内容为已知字符串
  17 + `"# Skill One\n\nBody text.\n"`。
  18 +- SQLite `skill` 表插入一行:
  19 +
  20 + ```
  21 + skill_id='skill-1', owner_org_node='coder', file_path='skills/skill-1',
  22 + current_git_commit_sha='unknown', source='migrated_from_hermes',
  23 + migrated_from_profile='code', status='active'
  24 + ```
  25 +
  26 +- `skill_iteration` 表为空(对应 PRD 背景中"当前没有迭代记录"这句话本身正确的前提)。
  27 +
  28 +每个场景会在此基线上做局部覆盖(如删除文件、篡改 `file_path`、增加支持文件等),并在场景描
  29 +述中明确写出与基线的差异。
  30 +
  31 +---
  32 +
  33 +## 1. 单元层场景:`src/skill_query.py::get_skill_content()`
  34 +
  35 +### U1(happy path)正常内容读取,无支持文件
  36 +
  37 +- **输入**:基线 fixture,调用 `get_skill_content(conn, "skill-1", repo_path=repo_root)`。
  38 +- **预期行为**:函数正常返回,不抛任何异常。
  39 +- **通过标准**(逐字段断言):
  40 + ```python
  41 + result = get_skill_content(conn, "skill-1", repo_path=repo_root)
  42 + assert result == {
  43 + "skill_id": "skill-1",
  44 + "owner_org_node": "coder",
  45 + "file_path": "skills/skill-1",
  46 + "current_git_commit_sha": "unknown",
  47 + "source": "migrated_from_hermes",
  48 + "migrated_from_profile": "code",
  49 + "status": "active",
  50 + "content": "# Skill One\n\nBody text.\n",
  51 + "supporting_files": [],
  52 + }
  53 + ```
  54 +- **对应测试**:`GetSkillContentTest.test_returns_real_file_content`(重写)。
  55 +
  56 +### U2(happy path)支持文件枚举,多层子目录
  57 +
  58 +- **输入**:在基线基础上,额外创建:
  59 + - `repo_root/skills/skill-1/references/foo.md`
  60 + - `repo_root/skills/skill-1/scripts/bar.py`
  61 + - `repo_root/skills/skill-1/SKILL.md` 内容不变。
  62 +- **预期行为**:`content` 字段仍是 `SKILL.md` 全文;`supporting_files` 列出另外两个文件,使用
  63 + 相对 `skill_dir` 的相对路径,且**升序排序**、**不包含 `SKILL.md` 自身**。
  64 +- **通过标准**:
  65 + ```python
  66 + result = get_skill_content(conn, "skill-1", repo_path=repo_root)
  67 + assert result["content"] == "# Skill One\n\nBody text.\n"
  68 + assert result["supporting_files"] == ["references/foo.md", "scripts/bar.py"]
  69 + assert "SKILL.md" not in result["supporting_files"]
  70 + ```
  71 +- **对应测试**:新增/重写 `GetSkillContentTest.test_lists_supporting_files_sorted`。
  72 +
  73 +### U3(boundary)目录下只有 `SKILL.md`,无其他文件
  74 +
  75 +- **输入**:基线 fixture(无额外文件)。
  76 +- **预期行为**:`supporting_files == []`(空列表,不是 `None`,不是缺字段)。
  77 +- **通过标准**:`isinstance(result["supporting_files"], list) and result["supporting_files"] == []`。
  78 +- **对应测试**:并入 U1 断言即可覆盖,无需单独用例。
  79 +
  80 +### U4(failure→非异常语义)`SKILL.md` 磁盘文件缺失,DB 行仍存在
  81 +
  82 +- **输入**:DB 中 `skill-1` 行照常存在,但删除/从不创建
  83 + `repo_root/skills/skill-1/SKILL.md`(`repo_root/skills/skill-1/` 目录本身也不存在,或存在但
  84 + 为空目录——两种子场景都要覆盖)。
  85 +- **预期行为**:**不抛出任何异常**(不是 `SkillContentMissingError`,该类已被删除)。函数正常
  86 + 返回一个 dict,`content` 字段为 `None`。
  87 +- **通过标准**:
  88 + ```python
  89 + result = get_skill_content(conn, "skill-1", repo_path=repo_root)
  90 + assert result["content"] is None
  91 + assert result["supporting_files"] == [] # 目录不存在时 os.walk 返回空,不报错
  92 + assert result["skill_id"] == "skill-1" # 其余字段仍从 DB 正常返回
  93 + ```
  94 +- **对应测试**:`GetSkillContentTest.test_missing_file_returns_none_content`(重写,原断言
  95 + `assertRaises(SkillContentMissingError)` 改为断言 `content is None` 且不抛异常)。
  96 +
  97 +### U5(failure)`skill_id` 在 `skill` 表中不存在
  98 +
  99 +- **输入**:`get_skill_content(conn, "skill-does-not-exist", repo_path=repo_root)`。
  100 +- **预期行为**:抛出 `SkillNotFoundError`,且**不做任何文件系统访问**(DB 查询先于文件路径解
  101 + 析)。
  102 +- **通过标准**:
  103 + ```python
  104 + with self.assertRaises(skill_query.SkillNotFoundError):
  105 + get_skill_content(conn, "skill-does-not-exist", repo_path=repo_root)
  106 + ```
  107 +- **对应测试**:既有 `GetSkillContentTest.test_nonexistent_skill_raises_not_found`(保留,行为
  108 + 未变,仅确认契约变更没有波及此分支)。
  109 +
  110 +### U6(failure/security)路径穿越——`file_path` 含相对 `../` 跳出 `skills/` 目录
  111 +
  112 +- **输入**:DB 中插入一行 `skill_id='evil-1'`,`file_path='skills/../../etc/passwd'`(或等价的
  113 + `../` 相对路径写法,具体依 `_skill_file_path()` 的既有解析规则构造)。调用
  114 + `get_skill_content(conn, "evil-1", repo_path=repo_root)`。
  115 +- **预期行为**:抛出 `SkillPathTraversalError`(不是 `SkillContentMissingError`——该类型已删
  116 + 除)。**在异常抛出前,函数从未调用 `open()`**——用哨兵验证:在越界目标路径预先放置一个内容
  117 + 可识别的哨兵文件(例如 repo 外层一个 `sentinel.txt` 写入 `"SENTINEL-SHOULD-NEVER-BE-READ"`),
  118 + 断言异常抛出且该哨兵内容从未出现在任何返回值或日志中。
  119 +- **通过标准**:
  120 + ```python
  121 + with self.assertRaises(skill_query.SkillPathTraversalError):
  122 + get_skill_content(conn, "evil-1", repo_path=repo_root)
  123 + # 哨兵文件从未被打开:可通过 mock open() 断言 open 从未以哨兵路径为参数被调用,
  124 + # 或通过文件系统 side-channel(哨兵文件 mtime/访问标记不变)间接验证
  125 + ```
  126 +- **对应测试**:`GetSkillContentTest.test_traversal_via_relative_dotdot_in_file_path`(重写,
  127 + 异常类型改为 `SkillPathTraversalError`)。
  128 +
  129 +### U7(failure/security)路径穿越——`file_path` 通过目录组件字面量跳出(如 `skills-evil/`)
  130 +
  131 +- **输入**:DB 中插入一行 `skill_id='evil-2'`,`file_path='skills-evil/leak'`(`realpath` 前缀
  132 + 匹配上容易因字符串前缀误判为在 `skills_root` 内,例如 `"...skills"` 是 `"...skills-evil"` 的
  133 + 字符串前缀但不是路径前缀——用以验证用的是 `os.path.commonpath`,不是朴素的 `str.startswith`)。
  134 +- **预期行为**:抛出 `SkillPathTraversalError`。
  135 +- **通过标准**:
  136 + ```python
  137 + with self.assertRaises(skill_query.SkillPathTraversalError):
  138 + get_skill_content(conn, "evil-2", repo_path=repo_root)
  139 + ```
  140 +- **对应测试**:`GetSkillContentTest.test_traversal_via_directory_component_returns_path_traversal_error`
  141 + (重写,函数名/断言从 `..._returns_content_missing` 改为反映新异常类型)。
  142 +
  143 +### U8(boundary)`SkillContentMissingError` 符号已被移除
  144 +
  145 +- **输入**:静态检查,非运行时用例。
  146 +- **预期行为**:`src/skill_query.py` 中不再存在 `SkillContentMissingError` 类定义;
  147 + `src/backend_server.py` 中不再有引用该符号的 `except` 分支。
  148 +- **通过标准**:`grep -rn SkillContentMissingError src/` 返回空结果。
  149 +- **验证方式**:Code Review / grep,非单元测试断言(记录在 DoD 清单中)。
  150 +
  151 +---
  152 +
  153 +## 2. 路由层场景:`GET /api/skills/:skill_id`(`SkillRouteTest`)
  154 +
  155 +共享前置:`backend_server` 测试基线起一个真实 `HTTPServer` 线程或使用既有测试基建(与
  156 +`SkillRouteTest` 现有其他用例一致的启动方式),DB fixture 同第 0 节。
  157 +
  158 +### R1(happy path)正常内容,200
  159 +
  160 +- **请求**:`GET /api/skills/skill-1`(基线 fixture,含 U2 的两个支持文件)。
  161 +- **预期状态码**:`200`。
  162 +- **预期响应体**(JSON,逐字段):
  163 + ```json
  164 + {
  165 + "skill_id": "skill-1",
  166 + "owner_org_node": "coder",
  167 + "file_path": "skills/skill-1",
  168 + "current_git_commit_sha": "unknown",
  169 + "source": "migrated_from_hermes",
  170 + "migrated_from_profile": "code",
  171 + "status": "active",
  172 + "content": "# Skill One\n\nBody text.\n",
  173 + "supporting_files": ["references/foo.md", "scripts/bar.py"]
  174 + }
  175 + ```
  176 +- **通过标准**:状态码等值比较 + 响应体逐 key 相等比较(不是子集匹配,字段名必须恰好是
  177 + `supporting_files`,不是 `support_files`)。
  178 +- **对应测试**:`SkillRouteTest.test_content_returns_real_content`(重写,URL 从
  179 + `/api/skills/skill-1/content` 改为 `/api/skills/skill-1`,字段名同步改)。
  180 +
  181 +### R2(boundary)磁盘文件缺失,200 + `content: null`
  182 +
  183 +- **请求**:`GET /api/skills/skill-1`,DB 行存在但 `SKILL.md` 文件已删除(同 U4 场景)。
  184 +- **预期状态码**:`200`(**不是** 404——这是本次契约变更的核心行为差异之一)。
  185 +- **预期响应体**:
  186 + ```json
  187 + {"skill_id": "skill-1", "...": "...", "content": null, "supporting_files": []}
  188 + ```
  189 +- **通过标准**:`resp.status == 200` 且 `json.loads(resp.body)["content"] is None`。
  190 +- **对应测试**:新增 `SkillRouteTest.test_content_missing_file_returns_200_null`(路由层此前
  191 + 完全没有覆盖越界/缺失场景,仅 `skill_query` 单元层有——本用例是 SDD §6 明确要求新增,不是重
  192 + 写)。
  193 +
  194 +### R3(failure)skill 不存在,404
  195 +
  196 +- **请求**:`GET /api/skills/does-not-exist`。
  197 +- **预期状态码**:`404`。
  198 +- **预期响应体**:`{"error": "NOT_FOUND"}`。
  199 +- **通过标准**:状态码 + body 精确匹配。
  200 +- **对应测试**:`SkillRouteTest.test_content_nonexistent_skill_returns_404`(重写,URL 改为裸
  201 + `skill_id`)。
  202 +
  203 +### R4(failure/security)路径穿越,400
  204 +
  205 +- **请求**:`GET /api/skills/evil-1`,DB 中该行 `file_path` 为越界值(同 U6 fixture)。
  206 +- **预期状态码**:`400`(**不是** 404,也不是 500)。
  207 +- **预期响应体**:`{"error": "INVALID_SKILL_ID"}`。
  208 +- **通过标准**:状态码 + body 精确匹配;额外断言响应体中不含任何越界目标路径的文件内容片段。
  209 +- **对应测试**:新增 `SkillRouteTest.test_content_path_traversal_returns_400`(路由层新增,
  210 + SDD §6 明确要求)。
  211 +
  212 +### R5(boundary)路由匹配优先级——`/audit` 后缀不被裸 `skill_id` 分支误吞
  213 +
  214 +- **请求**:`GET /api/skills/skill-1/audit`(既有的迭代审计接口)。
  215 +- **预期行为**:命中既有 `_handle_skill_audit_log`,**不**命中新的裸 `skill_id` 分支(新分支要
  216 + 求 `"/" not in skill_id`,`"skill-1/audit"` 含 `/`,天然不满足,但需要显式测试验证路由顺序
  217 + 没有被写反)。
  218 +- **预期状态码/响应体**:与改动前完全一致——`skill_iteration` 表为空时返回 `200` + `[]`。
  219 +- **通过标准**:`resp.status == 200 and json.loads(resp.body) == []`。
  220 +- **对应测试**:既有 `SkillRouteTest.test_audit_log_empty_returns_empty_list`(原样保留,作为
  221 + 路由顺序回归证据,不修改)。
  222 +
  223 +### R6(boundary)路由匹配优先级——`/diff` 端点不受影响
  224 +
  225 +- **请求**:`GET /api/skills/diff?...`(既有 diff 接口的合法查询)。
  226 +- **预期行为**:命中既有 `_handle_skill_diff`分支(在裸 `skill_id` 分支**之前**注册),行为与
  227 + 改动前完全一致。
  228 +- **通过标准**:既有 `GetDiffTest`/`SkillRouteTest.test_diff_*` 全部保持通过,不需新增用例。
  229 +
  230 +### R7(failure)`POST /api/skills/:skill_id/rollback` 不受本次改动影响
  231 +
  232 +- **请求**:既有 `test_rollback_*` 用例的请求不变。
  233 +- **预期行为**:`do_POST` 分支未改动,行为完全一致。
  234 +- **通过标准**:既有 `SkillRouteTest.test_rollback_*` 全部保持通过,不需新增用例。
  235 +
  236 +---
  237 +
  238 +## 3. 前端手工验证场景(`frontend/app.js`,无自动化测试基建,与 PRD §8 一致)
  239 +
  240 +环境:`python3 -m src.backend_server --port 8420`,浏览器打开
  241 +`http://127.0.0.1:8420/#/skills`。
  242 +
  243 +### F1(happy path)点击已迁移 Skill,两面板并列显示
  244 +
  245 +- **操作**:在 `#/skills` 列表点击任一真实存在磁盘文件的 Skill(如迁移后的 `coder`/`planner`
  246 + 等真实 skill_id)。
  247 +- **预期页面**:跳转到 `#/skills/:id/audit`;页面同时可见两个独立面板:
  248 + 1. "Skill 内容"面板:`<pre class="code">` 元素内是该 Skill `SKILL.md` 的完整原始文本(可
  249 + 用浏览器"查看源代码"/`document.getElementById("skill_content")` 核对文本与磁盘文件字节
  250 + 一致,包括换行)。
  251 + 2. "迭代记录"面板:显示"该 Skill 尚无自主迭代记录"(因为 `skill_iteration` 表为空,这是
  252 + PRD 背景中确认为正确的既有行为,不应被本次改动改变)。
  253 +- **通过标准**:两个面板同时非空/各自呈现预期内容,其中一个面板的渲染逻辑失败不影响另一个(
  254 + 可通过临时让 `/audit` 请求失败——如断网模拟——验证"Skill 内容"面板仍正常显示)。
  255 +
  256 +### F2(boundary)无第三方渲染依赖
  257 +
  258 +- **操作**:打开浏览器开发者工具 Network 面板,刷新 `#/skills/:id/audit` 页面。
  259 +- **预期行为**:页面加载的脚本资源里不出现任何 Markdown 渲染库或代码高亮库(如
  260 + `marked.js`/`highlight.js`/`prismjs` 等);"Skill 内容"面板的 DOM 元素是纯 `<pre>` 文本节
  261 + 点,未被转换为渲染后的 HTML 结构(如 `<h1>`/`<code class="language-*">`)。
  262 +- **通过标准**:Network 面板资源列表 + `document.getElementById("skill_content").innerHTML`
  263 + 中除转义后的原始 Markdown 文本外不含渲染产物标签。
  264 +
  265 +### F3(failure)磁盘文件缺失时的降级显示
  266 +
  267 +- **操作**:临时删除某个已迁移 Skill 目录下的 `SKILL.md`(测试环境操作,验证后恢复),刷新对
  268 + 应详情页。
  269 +- **预期页面**:"Skill 内容"面板显示文案"(内容文件不可用)",不抛 JS 异常,不出现空白面
  270 + 板或浏览器控制台报错;"迭代记录"面板不受影响,正常渲染。
  271 +- **通过标准**:浏览器控制台无未捕获异常;"Skill 内容"面板文本节点内容精确等于
  272 + "(内容文件不可用)"。
  273 +
  274 +### F4(failure)Skill 完全不存在时的整页错误态
  275 +
  276 +- **操作**:直接在地址栏输入 `http://127.0.0.1:8420/#/skills/does-not-exist/audit`。
  277 +- **预期页面**:页面顶部出现统一错误横幅,文案含"该 Skill 不存在";"Skill 内容"面板与"迭
  278 + 代记录"面板均不渲染任何内容(不是各自显示两条不一致的错误提示)。
  279 +- **通过标准**:`#skill_audit_error` 元素 `display` 不为 `none` 且文本含"不存在";
  280 + `#skill_content`、`#skill_audit_list` 的 `innerHTML` 均为空字符串。
  281 +
  282 +### F5(boundary)支持文件清单展示
  283 +
  284 +- **前置**:为某个测试用 Skill 目录额外放置 `references/example.md`。
  285 +- **操作**:打开该 Skill 详情页。
  286 +- **预期页面**:"Skill 内容"面板在正文 `<pre>` 之外(之前或之后,与现有 DOM 结构一致的位
  287 + 置)显示一行提示,形如"支持文件:references/example.md";若无支持文件的 Skill 则显示"无
  288 + 支持文件"。
  289 +- **通过标准**:对应 hint 文本节点内容与磁盘实际支持文件列表一致;不存在支持文件的 Skill 显
  290 + 示"无支持文件"而不是空白或报错。
  291 +
  292 +---
  293 +
  294 +## 4. 回归范围:既有测试必须保持通过,断言不得修改
  295 +
  296 +以下测试类/方法不在本次契约变更范围内,运行 `python3 -m unittest src.test_skill_query -v` 与
  297 +`python3 -m unittest src.test_backend_server.SkillRouteTest -v` 时必须保持全绿,且其断言内容
  298 +(包括请求 URL、期望字段名、期望状态码)**逐字不变**:
  299 +
  300 +- `src/test_skill_query.py`:`ListSkillsTest` 全部用例、`GetSkillAuditLogTest` 全部用例、
  301 + `GetDiffTest` 全部用例。
  302 +- `src/test_backend_server.py::SkillRouteTest`:`test_list_returns_seeded_skill`、
  303 + `test_audit_log_*`、`test_diff_*`、`test_full_update_diff_rollback_cycle`、
  304 + `test_rollback_*`。
  305 +
  306 +**通过标准**:`git diff` 中上述测试方法体不出现在改动范围内(除非是纯粹的、不改变断言语义的
  307 +重排/import 调整)。若 diff 触碰了这些方法的断言内容,视为违反 SDD §0 裁决,判定为不通过。
  308 +
  309 +---
  310 +
  311 +## 5. 全量回归
  312 +
  313 +- **命令**:`python3 -m unittest discover -s src`。
  314 +- **通过标准**:全部用例通过(0 failure,0 error),特别关注
  315 + `src/test_agent_profile.py`、`src/test_skill_update.py` 等间接依赖 `skill_query`/
  316 + `skill_update` 模块符号的测试文件不因 `SkillContentMissingError` 删除而出现
  317 + `ImportError`/`AttributeError`。
  318 +
  319 +---
  320 +
  321 +## 6. 验收标准总表(PRD AC 逐条 → 本文档场景编号)
  322 +
  323 +| PRD AC | 描述 | 覆盖场景 |
  324 +|---|---|---|
  325 +| AC1 | 真实内容以 `<pre>` 展示 | F1、F2、R1 |
  326 +| AC2 | 变更审计面板行为不变 | R5、F1(迭代记录部分)|
  327 +| AC3 | 两面板并列、互不依赖 | F1、F3(局部失败不影响另一面板)|
  328 +| AC4 | 文件缺失显示"内容文件不可用",不 500 | U4、R2、F3 |
  329 +| AC5 | skill 不存在 → 整页错误态,两面板都不渲染,404 | U5、R3、F4 |
  330 +| AC6 | 越界 `file_path` 不读出目录外内容,400 非 500 | U6、U7、R4 |
  331 +| AC7 | 既有测试全通过、断言不被无理由篡改 | 第 4 节回归范围 |
  332 +| AC8 | 前端无新第三方依赖 | F2 |
  333 +
  334 +---
  335 +
  336 +## 7. Definition of Done 检查清单(可勾选执行)
  337 +
  338 +- [ ] U1–U8 全部通过(`GetSkillContentTest` 重写后的类)。
  339 +- [ ] R1–R7 全部通过(`SkillRouteTest` 重写 + 新增用例)。
  340 +- [ ] `grep -rn SkillContentMissingError src/` 输出为空。
  341 +- [ ] `python3 -m unittest src.test_skill_query -v` 全绿。
  342 +- [ ] `python3 -m unittest src.test_backend_server.SkillRouteTest -v` 全绿。
  343 +- [ ] `python3 -m unittest discover -s src` 全绿。
  344 +- [ ] F1–F5 手工验证全部通过(浏览器实测,非自动化)。
  345 +- [ ] 第 4 节列出的既有测试方法体逐字未改动(`git diff` 复核)。
... ...