Commit 378fccd8efd9687f52ac13309e9c40a32e0ca381

Authored by graphlemon
2 parents 2b8c2747 496a31d2

graphlemon: merge 3df67d31-b52c-4abe-bfcb-fbb16252020d

scripts/benchmark_skill_content_contract.py 0 → 100644
  1 +#!/usr/bin/env python3
  2 +"""Mechanical Benchmark check for "Skill 详情页展示真实内容 (`v_skill_audit_log` 契约收口)".
  3 +
  4 +Validates the target contract described in the approved Benchmark for this
  5 +goal: `src/skill_query.py::get_skill_content()`, the `GET /api/skills/<id>`
  6 +route in `src/backend_server.py`, and `frontend/app.js::mountSkillAuditLog`
  7 +must together show a Skill's real on-disk `SKILL.md` content next to its
  8 +existing iteration-audit panel, reject path traversal, and drop the old
  9 +`SkillContentMissingError` / `support_files` / `.../content` route names
  10 +without a compatibility shim -- and the change must actually be committed,
  11 +not just sitting in an uncommitted working-tree diff.
  12 +
  13 +This script is intentionally self-contained (not wired into `python3 -m
  14 +unittest discover -s src`, so it can be dropped anywhere in the checkout and
  15 +still find the repo root on its own) and is expected to FAIL against the
  16 +pre-fix code -- that is its job: it is the acceptance test the Coder's
  17 +change must turn green, not a regression test for something already done.
  18 +
  19 +Run:
  20 + python3 scripts/benchmark_skill_content_contract.py -v
  21 +or, since it's a normal unittest module:
  22 + python3 -m unittest -v scripts.benchmark_skill_content_contract
  23 +"""
  24 +
  25 +import http.client
  26 +import json
  27 +import os
  28 +import re
  29 +import shutil
  30 +import subprocess
  31 +import sys
  32 +import tempfile
  33 +import threading
  34 +import unittest
  35 +from unittest import mock
  36 +
  37 +
  38 +def _find_repo_root(start):
  39 + d = start
  40 + while True:
  41 + if os.path.isfile(os.path.join(d, "src", "skill_query.py")):
  42 + return d
  43 + parent = os.path.dirname(d)
  44 + if parent == d:
  45 + raise RuntimeError("could not locate graphlemon repo root (no src/skill_query.py found above %s)" % start)
  46 + d = parent
  47 +
  48 +
  49 +REPO_ROOT = _find_repo_root(os.path.dirname(os.path.abspath(__file__)))
  50 +if REPO_ROOT not in sys.path:
  51 + sys.path.insert(0, REPO_ROOT)
  52 +
  53 +from src import backend_server, schema, skill_query, skill_update # noqa: E402
  54 +
  55 +SAMPLE_CONTENT = "real skill markdown body\nwith <two> lines & a quote \"here\"\n"
  56 +
  57 +
  58 +def _init_git_repo(path):
  59 + subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
  60 +
  61 +
  62 +# ---------------------------------------------------------------------------
  63 +# 1. src/skill_query.py::get_skill_content() -- T1.1-T1.7
  64 +# ---------------------------------------------------------------------------
  65 +class SkillContentUnitTest(unittest.TestCase):
  66 + def setUp(self):
  67 + self.tmp = tempfile.TemporaryDirectory()
  68 + self.addCleanup(self.tmp.cleanup)
  69 + _init_git_repo(self.tmp.name)
  70 + self.conn = schema.connect(":memory:")
  71 + schema.create_schema(self.conn)
  72 + self.conn.commit()
  73 + self.addCleanup(self.conn.close)
  74 +
  75 + def _insert(self, skill_id, file_path, owner_org_node="coder", source="migrated_from_hermes",
  76 + migrated_from_profile="code", current_git_commit_sha="abc123", status="active"):
  77 + self.conn.execute(
  78 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, "
  79 + "migrated_from_profile, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
  80 + (skill_id, owner_org_node, file_path, current_git_commit_sha, source, migrated_from_profile, status),
  81 + )
  82 + self.conn.commit()
  83 +
  84 + def _write_skill_file(self, rel_dir, content=None, extra_files=None):
  85 + skill_dir = os.path.join(self.tmp.name, rel_dir)
  86 + os.makedirs(skill_dir, exist_ok=True)
  87 + if content is not None:
  88 + with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f:
  89 + f.write(content)
  90 + for rel in extra_files or []:
  91 + full = os.path.join(skill_dir, rel)
  92 + os.makedirs(os.path.dirname(full), exist_ok=True)
  93 + with open(full, "w", encoding="utf-8") as f:
  94 + f.write("support file body")
  95 +
  96 + def test_t1_1_normal_content_exact_shape(self):
  97 + self._insert("skill-1", "skills/skill-1")
  98 + self._write_skill_file("skills/skill-1", content=SAMPLE_CONTENT)
  99 + result = skill_query.get_skill_content(self.conn, "skill-1", repo_path=self.tmp.name)
  100 + self.assertEqual(
  101 + set(result.keys()),
  102 + {"skill_id", "owner_org_node", "file_path", "current_git_commit_sha", "source",
  103 + "migrated_from_profile", "status", "content", "supporting_files"},
  104 + "get_skill_content must return exactly these 9 keys, field name is `supporting_files`",
  105 + )
  106 + self.assertEqual(result["skill_id"], "skill-1")
  107 + self.assertEqual(result["owner_org_node"], "coder")
  108 + self.assertEqual(result["file_path"], "skills/skill-1")
  109 + self.assertEqual(result["current_git_commit_sha"], "abc123")
  110 + self.assertEqual(result["source"], "migrated_from_hermes")
  111 + self.assertEqual(result["migrated_from_profile"], "code")
  112 + self.assertEqual(result["status"], "active")
  113 + self.assertEqual(result["content"], SAMPLE_CONTENT)
  114 + self.assertEqual(result["supporting_files"], [])
  115 +
  116 + def test_t1_2_missing_file_returns_none_content_no_exception(self):
  117 + self._insert("skill-missing-file", "skills/skill-missing-file")
  118 + result = skill_query.get_skill_content(self.conn, "skill-missing-file", repo_path=self.tmp.name)
  119 + self.assertIsNone(result["content"])
  120 + self.assertEqual(result["supporting_files"], [])
  121 + self.assertEqual(result["skill_id"], "skill-missing-file")
  122 +
  123 + def test_t1_3_nonexistent_skill_raises_not_found(self):
  124 + with self.assertRaises(skill_query.SkillNotFoundError):
  125 + skill_query.get_skill_content(self.conn, "does-not-exist", repo_path=self.tmp.name)
  126 +
  127 + def test_t1_4_path_traversal_whole_path_escape_rejected_before_open(self):
  128 + self._insert("skill-evil", "../../etc/passwd")
  129 + with mock.patch("builtins.open") as mock_open:
  130 + with self.assertRaises(skill_query.SkillPathTraversalError):
  131 + skill_query.get_skill_content(self.conn, "skill-evil", repo_path=self.tmp.name)
  132 + mock_open.assert_not_called()
  133 +
  134 + def test_t1_5_path_traversal_dotdot_segment_rejected_before_open(self):
  135 + self._insert("skill-evil-2", "skills/../../../tmp/evil")
  136 + with mock.patch("builtins.open") as mock_open:
  137 + with self.assertRaises(skill_query.SkillPathTraversalError):
  138 + skill_query.get_skill_content(self.conn, "skill-evil-2", repo_path=self.tmp.name)
  139 + mock_open.assert_not_called()
  140 +
  141 + def test_t1_6_supporting_files_sorted_relative_excludes_main_file(self):
  142 + self._insert("skill-with-support", "skills/skill-with-support")
  143 + self._write_skill_file(
  144 + "skills/skill-with-support", content="body\n",
  145 + extra_files=["scripts/bar.py", "references/foo.md"],
  146 + )
  147 + result = skill_query.get_skill_content(self.conn, "skill-with-support", repo_path=self.tmp.name)
  148 + self.assertEqual(result["supporting_files"], ["references/foo.md", "scripts/bar.py"])
  149 +
  150 + def test_t1_7_skill_content_missing_error_class_removed(self):
  151 + for root, _dirs, files in os.walk(os.path.join(REPO_ROOT, "src")):
  152 + for name in files:
  153 + if not name.endswith(".py"):
  154 + continue
  155 + full = os.path.join(root, name)
  156 + with open(full, "r", encoding="utf-8", errors="replace") as f:
  157 + text = f.read()
  158 + self.assertNotIn(
  159 + "SkillContentMissingError", text,
  160 + "SkillContentMissingError must be fully removed, still referenced in " + full,
  161 + )
  162 +
  163 +
  164 +# ---------------------------------------------------------------------------
  165 +# 2. src/backend_server.py route layer -- T2.1-T2.7
  166 +# ---------------------------------------------------------------------------
  167 +class SkillContentRouteTest(unittest.TestCase):
  168 + def setUp(self):
  169 + self.tmp = tempfile.TemporaryDirectory()
  170 + self.addCleanup(self.tmp.cleanup)
  171 + _init_git_repo(self.tmp.name)
  172 + self.server = backend_server.create_server(db_path=":memory:", skill_repo_path=self.tmp.name)
  173 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  174 + self.thread.start()
  175 + self.addCleanup(self._stop_server)
  176 +
  177 + self.server.conn.execute(
  178 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, "
  179 + "migrated_from_profile, status) VALUES ('skill-1', 'coder', 'skills/skill-1', 'abc123', "
  180 + "'migrated_from_hermes', 'code', 'active')"
  181 + )
  182 + self.server.conn.execute(
  183 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  184 + "VALUES ('skill-missing-file', 'coder', 'skills/skill-missing-file', 'unknown', 'new', 'active')"
  185 + )
  186 + self.server.conn.execute(
  187 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  188 + "VALUES ('skill-evil', 'coder', '../../etc/passwd', 'unknown', 'new', 'active')"
  189 + )
  190 + self.server.conn.execute(
  191 + "INSERT INTO bug_record (bug_id, source, description, root_cause_category, root_cause_detail, "
  192 + "suggested_route, status) VALUES ('bug-1', 'validator', 'x', 'skill_defect', 'd', 'fast_lane', 'routed')"
  193 + )
  194 + self.server.conn.commit()
  195 +
  196 + skill_dir = os.path.join(self.tmp.name, "skills", "skill-1")
  197 + os.makedirs(skill_dir, exist_ok=True)
  198 + with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f:
  199 + f.write(SAMPLE_CONTENT)
  200 +
  201 + def _stop_server(self):
  202 + self.server.shutdown()
  203 + self.server.server_close()
  204 + self.thread.join(timeout=5)
  205 +
  206 + def _request(self, method, path, payload=None):
  207 + host, port = self.server.server_address
  208 + conn = http.client.HTTPConnection(host, port, timeout=5)
  209 + body = json.dumps(payload).encode("utf-8") if payload is not None else None
  210 + conn.request(method, path, body=body)
  211 + resp = conn.getresponse()
  212 + raw = resp.read()
  213 + conn.close()
  214 + parsed = json.loads(raw) if raw else None
  215 + return resp.status, parsed
  216 +
  217 + def _get(self, path):
  218 + return self._request("GET", path)
  219 +
  220 + def test_t2_1_get_skill_detail_200_exact_shape(self):
  221 + status, body = self._get("/api/skills/skill-1")
  222 + self.assertEqual(status, 200)
  223 + self.assertEqual(
  224 + set(body.keys()),
  225 + {"skill_id", "owner_org_node", "file_path", "current_git_commit_sha", "source",
  226 + "migrated_from_profile", "status", "content", "supporting_files"},
  227 + )
  228 + self.assertEqual(body["content"], SAMPLE_CONTENT)
  229 + self.assertEqual(body["supporting_files"], [])
  230 +
  231 + def test_t2_2_nonexistent_skill_404(self):
  232 + status, body = self._get("/api/skills/does-not-exist")
  233 + self.assertEqual(status, 404)
  234 + self.assertEqual(body, {"error": "NOT_FOUND"})
  235 +
  236 + def test_t2_3_missing_file_still_200_null_content(self):
  237 + status, body = self._get("/api/skills/skill-missing-file")
  238 + self.assertEqual(status, 200)
  239 + self.assertIsNone(body["content"])
  240 + self.assertNotIn("error", body)
  241 +
  242 + def test_t2_4_path_traversal_400_invalid_skill_id(self):
  243 + status, body = self._get("/api/skills/skill-evil")
  244 + self.assertEqual(status, 400)
  245 + self.assertEqual(body, {"error": "INVALID_SKILL_ID"})
  246 +
  247 + def test_t2_5_audit_and_diff_routes_not_swallowed_by_bare_skill_id_branch(self):
  248 + status, body = self._get("/api/skills/skill-1/audit")
  249 + self.assertEqual(status, 200)
  250 + self.assertIsInstance(body, list, "/audit must still return the iteration-list shape, not skill detail JSON")
  251 +
  252 + status, body = self._get("/api/skills/diff")
  253 + self.assertEqual(status, 400)
  254 + self.assertEqual(body["error"], "INVALID_INPUT")
  255 +
  256 + def test_t2_6_rollback_route_unaffected(self):
  257 + skill_update._write_and_commit(self.tmp.name, "skills/skill-1/SKILL.md", "v1 content", "seed skill")
  258 + with self.server.conn_lock:
  259 + result = skill_update.update_skill(
  260 + self.server.conn, "skill-1", "bug-1", "v2 content", "improve it", self.tmp.name
  261 + )
  262 + status, body = self._request("POST", "/api/skills/skill-1/rollback", {"iteration_id": result["iteration_id"]})
  263 + self.assertEqual(status, 200)
  264 + self.assertIn("new_iteration_id", body)
  265 +
  266 + def test_t2_7_old_content_suffixed_route_now_404(self):
  267 + status, _body = self._get("/api/skills/skill-1/content")
  268 + self.assertEqual(status, 404)
  269 +
  270 +
  271 +# ---------------------------------------------------------------------------
  272 +# 4. Defense in depth -- T4.1
  273 +# ---------------------------------------------------------------------------
  274 +class DefenseInDepthTest(unittest.TestCase):
  275 + def test_t4_1_sentinel_marker_never_leaks(self):
  276 + sentinel_dir = tempfile.TemporaryDirectory()
  277 + self.addCleanup(sentinel_dir.cleanup)
  278 + with open(os.path.join(sentinel_dir.name, "SKILL.md"), "w", encoding="utf-8") as f:
  279 + f.write("SENTINEL_MARKER_xyz")
  280 +
  281 + repo_tmp = tempfile.TemporaryDirectory()
  282 + self.addCleanup(repo_tmp.cleanup)
  283 + _init_git_repo(repo_tmp.name)
  284 + conn = schema.connect(":memory:")
  285 + schema.create_schema(conn)
  286 + self.addCleanup(conn.close)
  287 +
  288 + skills_root = os.path.join(repo_tmp.name, "skills")
  289 + rel = os.path.relpath(sentinel_dir.name, skills_root) # dir with no trailing .md -> "<rel>/SKILL.md"
  290 + conn.execute(
  291 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  292 + "VALUES ('skill-evil', 'coder', ?, 'unknown', 'new', 'active')",
  293 + (rel,),
  294 + )
  295 + conn.commit()
  296 +
  297 + leaked = False
  298 + try:
  299 + result = skill_query.get_skill_content(conn, "skill-evil", repo_path=repo_tmp.name)
  300 + leaked = "SENTINEL_MARKER_xyz" in json.dumps(result, default=str)
  301 + except skill_query.SkillPathTraversalError as e:
  302 + leaked = "SENTINEL_MARKER_xyz" in str(e) or "SENTINEL_MARKER_xyz" in repr(e.args)
  303 + self.assertFalse(leaked, "the sentinel marker must never appear in the return value or the raised exception")
  304 +
  305 +
  306 +# ---------------------------------------------------------------------------
  307 +# 3. frontend/app.js::mountSkillAuditLog -- T3.1-T3.4 (Node vm smoke test,
  308 +# same "no npm dependency" convention as frontend/test_graph_console_smoke.js)
  309 +# ---------------------------------------------------------------------------
  310 +_FRONTEND_SMOKE_JS = r"""
  311 +"use strict";
  312 +const fs = require("fs");
  313 +const vm = require("vm");
  314 +const appJsPath = process.argv[2];
  315 +const src = fs.readFileSync(appJsPath, "utf8");
  316 +
  317 +function makeFakeDiv() {
  318 + let text = "";
  319 + return {
  320 + set textContent(v) { text = v == null ? "" : String(v); },
  321 + get textContent() { return text; },
  322 + get innerHTML() {
  323 + return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
  324 + },
  325 + };
  326 +}
  327 +
  328 +function unescapeHtml(html) {
  329 + return html
  330 + .replace(/&quot;/g, '"')
  331 + .replace(/&gt;/g, ">")
  332 + .replace(/&lt;/g, "<")
  333 + .replace(/&amp;/g, "&");
  334 +}
  335 +
  336 +function makePanel() {
  337 + return {
  338 + _innerHTML: "",
  339 + _textContent: "",
  340 + style: { display: "" },
  341 + get innerHTML() { return this._innerHTML; },
  342 + set innerHTML(v) { this._innerHTML = v == null ? "" : String(v); },
  343 + get textContent() { return this._textContent; },
  344 + set textContent(v) { this._textContent = v == null ? "" : String(v); },
  345 + };
  346 +}
  347 +
  348 +function extractPreText(panel) {
  349 + const m = panel.innerHTML.match(/<pre[^>]*>([\s\S]*?)<\/pre>/);
  350 + return m ? unescapeHtml(m[1]) : null;
  351 +}
  352 +
  353 +let elements = {};
  354 +global.document = {
  355 + createElement: () => makeFakeDiv(),
  356 + getElementById: (id) => elements[id],
  357 + querySelectorAll: () => [],
  358 + addEventListener: () => {},
  359 +};
  360 +global.window = { location: { protocol: "http:", host: "localhost", hash: "" }, addEventListener: () => {} };
  361 +global.localStorage = { getItem: () => null, setItem: () => {} };
  362 +
  363 +// vm.runInThisContext (NOT vm.createContext) runs app.js in this process's
  364 +// own realm, so top-level `function ...` declarations attach straight to
  365 +// globalThis and fetch()'s real Promise machinery is the same Promise our
  366 +// assertions below use -- no cross-realm bridging needed for the async
  367 +// mountSkillAuditLog() flow.
  368 +vm.runInThisContext(src, { filename: "app.js" });
  369 +
  370 +let failures = 0;
  371 +function check(cond, msg) {
  372 + if (cond) { console.log("ok: " + msg); }
  373 + else { failures++; console.error("FAIL: " + msg); }
  374 +}
  375 +
  376 +function mockFetch(responses) {
  377 + return function fetch(url) {
  378 + const r = responses.find((entry) => entry.match(url));
  379 + if (!r) throw new Error("unexpected fetch url in test: " + url);
  380 + return new Promise((resolve) => {
  381 + setTimeout(() => resolve({ status: r.status, json: async () => r.json }), r.delayMs || 0);
  382 + });
  383 + };
  384 +}
  385 +
  386 +async function settle(ticks) {
  387 + for (let i = 0; i < (ticks || 6); i++) {
  388 + await new Promise((resolve) => setTimeout(resolve, 5));
  389 + }
  390 +}
  391 +
  392 +(async () => {
  393 + // ---- T3.1: normal content renders ----
  394 + {
  395 + elements = { skill_content: makePanel(), skill_audit_list: makePanel(), skill_audit_error: makePanel() };
  396 + global.fetch = mockFetch([
  397 + { match: (u) => u.endsWith("/audit"), status: 200, json: [] },
  398 + { match: (u) => !u.endsWith("/audit"), status: 200, json: {
  399 + skill_id: "skill-1", owner_org_node: "coder", file_path: "skills/skill-1",
  400 + current_git_commit_sha: "abc123", source: "migrated_from_hermes", migrated_from_profile: "code",
  401 + status: "active", content: SAMPLE_CONTENT_PLACEHOLDER, supporting_files: [],
  402 + } },
  403 + ]);
  404 + mountSkillAuditLog("skill-1");
  405 + await settle();
  406 + const preText = extractPreText(elements.skill_content);
  407 + check(preText === SAMPLE_CONTENT_PLACEHOLDER, "T3.1 <pre> textContent equals raw content exactly, got: " + JSON.stringify(preText));
  408 + check(elements.skill_content.innerHTML.includes("无支持文件"), "T3.1 empty supporting_files renders a '无支持文件' hint");
  409 + }
  410 +
  411 + // ---- T3.2: content null ----
  412 + {
  413 + elements = { skill_content: makePanel(), skill_audit_list: makePanel(), skill_audit_error: makePanel() };
  414 + global.fetch = mockFetch([
  415 + { match: (u) => u.endsWith("/audit"), status: 200, json: [] },
  416 + { match: (u) => !u.endsWith("/audit"), status: 200, json: {
  417 + skill_id: "skill-missing-file", owner_org_node: "coder", file_path: "skills/skill-missing-file",
  418 + current_git_commit_sha: "unknown", source: "new", migrated_from_profile: null,
  419 + status: "active", content: null, supporting_files: [],
  420 + } },
  421 + ]);
  422 + let threw = false;
  423 + try {
  424 + mountSkillAuditLog("skill-missing-file");
  425 + await settle();
  426 + } catch (e) { threw = true; console.error(e); }
  427 + check(!threw, "T3.2 no uncaught exception when content is null");
  428 + const preText = extractPreText(elements.skill_content);
  429 + check(preText === "(内容文件不可用)", "T3.2 <pre> textContent is the exact placeholder for null content, got: " + JSON.stringify(preText));
  430 + }
  431 +
  432 + // ---- T3.3: skill not found, shared error banner, order-independent ----
  433 + async function runNotFoundOrdering(detailDelay, auditDelay, label) {
  434 + elements = { skill_content: makePanel(), skill_audit_list: makePanel(), skill_audit_error: makePanel() };
  435 + global.fetch = mockFetch([
  436 + { match: (u) => u.endsWith("/audit"), status: 404, json: { error: "NOT_FOUND" }, delayMs: auditDelay },
  437 + { match: (u) => !u.endsWith("/audit"), status: 404, json: { error: "NOT_FOUND" }, delayMs: detailDelay },
  438 + ]);
  439 + mountSkillAuditLog("skill-x");
  440 + await settle(12);
  441 + check(elements.skill_content.innerHTML === "", label + ": skill_content cleared");
  442 + check(elements.skill_audit_list.innerHTML === "", label + ": skill_audit_list cleared");
  443 + check(elements.skill_audit_error.style.display === "block", label + ": error banner visible");
  444 + check(!!elements.skill_audit_error.textContent, label + ": error banner has non-empty text");
  445 + }
  446 + await runNotFoundOrdering(0, 25, "T3.3 detail-first");
  447 + await runNotFoundOrdering(25, 0, "T3.3 audit-first");
  448 +
  449 + // ---- T3.4: supporting files listed ----
  450 + {
  451 + elements = { skill_content: makePanel(), skill_audit_list: makePanel(), skill_audit_error: makePanel() };
  452 + global.fetch = mockFetch([
  453 + { match: (u) => u.endsWith("/audit"), status: 200, json: [] },
  454 + { match: (u) => !u.endsWith("/audit"), status: 200, json: {
  455 + skill_id: "skill-with-support", owner_org_node: "coder", file_path: "skills/skill-with-support",
  456 + current_git_commit_sha: "abc123", source: "migrated_from_hermes", migrated_from_profile: "code",
  457 + status: "active", content: "body\n", supporting_files: ["references/foo.md", "scripts/bar.py"],
  458 + } },
  459 + ]);
  460 + mountSkillAuditLog("skill-with-support");
  461 + await settle();
  462 + const html = elements.skill_content.innerHTML;
  463 + check(html.includes("references/foo.md"), "T3.4 references/foo.md listed in the hint");
  464 + check(html.includes("scripts/bar.py"), "T3.4 scripts/bar.py listed in the hint");
  465 + }
  466 +
  467 + if (failures > 0) {
  468 + console.error(failures + " assertion(s) failed");
  469 + process.exit(1);
  470 + }
  471 + console.log("frontend/app.js skill-content smoke: all assertions passed");
  472 +})();
  473 +"""
  474 +
  475 +
  476 +class FrontendSkillContentSmokeTest(unittest.TestCase):
  477 + def test_t3_1_to_t3_4_mount_skill_audit_log_contract(self):
  478 + node = shutil.which("node")
  479 + if node is None:
  480 + self.skipTest("node not on PATH -- required by this repo's own frontend/test_graph_console_smoke.js too")
  481 +
  482 + app_js_path = os.path.join(REPO_ROOT, "frontend", "app.js")
  483 + self.assertTrue(os.path.isfile(app_js_path), "frontend/app.js not found at " + app_js_path)
  484 +
  485 + workdir = tempfile.mkdtemp()
  486 + self.addCleanup(shutil.rmtree, workdir, True)
  487 + script_path = os.path.join(workdir, "skill_content_smoke.js")
  488 + js_content = _FRONTEND_SMOKE_JS.replace(
  489 + "SAMPLE_CONTENT_PLACEHOLDER", json.dumps(SAMPLE_CONTENT)
  490 + )
  491 + with open(script_path, "w", encoding="utf-8") as f:
  492 + f.write(js_content)
  493 +
  494 + result = subprocess.run([node, script_path, app_js_path], capture_output=True, text=True, timeout=60)
  495 + sys.stderr.write(result.stdout)
  496 + sys.stderr.write(result.stderr)
  497 + self.assertEqual(result.returncode, 0, "frontend smoke test failed, see stdout/stderr above")
  498 +
  499 +
  500 +# ---------------------------------------------------------------------------
  501 +# T1.7 (Python side, already covered above) + T3.5: static scan for
  502 +# residual old symbol/field/route names
  503 +# ---------------------------------------------------------------------------
  504 +class StaticScanTest(unittest.TestCase):
  505 + def test_t3_5_no_residual_field_name_or_content_route_in_app_js(self):
  506 + app_js = os.path.join(REPO_ROOT, "frontend", "app.js")
  507 + with open(app_js, "r", encoding="utf-8") as f:
  508 + text = f.read()
  509 + bad_matches = re.findall(r"(?<!ing_)support_files\b", text)
  510 + self.assertEqual(bad_matches, [], "app.js still references the old `support_files` field name")
  511 + self.assertNotIn('/content"', text, 'app.js still builds a "/content"-suffixed fetch URL')
  512 +
  513 +
  514 +# ---------------------------------------------------------------------------
  515 +# 5. Full regression -- T5.1/T5.2 (exact commands from the Benchmark)
  516 +# ---------------------------------------------------------------------------
  517 +class FullRegressionTest(unittest.TestCase):
  518 + def _run(self, args):
  519 + return subprocess.run([sys.executable] + args, cwd=REPO_ROOT, capture_output=True, text=True)
  520 +
  521 + def test_t5_1_named_existing_test_targets_still_pass(self):
  522 + commands = [
  523 + ["-m", "unittest", "src.test_skill_query.ListSkillsTest", "-v"],
  524 + ["-m", "unittest", "src.test_skill_query.GetSkillAuditLogTest", "-v"],
  525 + ["-m", "unittest", "src.test_skill_query.GetDiffTest", "-v"],
  526 + ["-m", "unittest", "src.test_backend_server.SkillRouteTest.test_list_returns_seeded_skill", "-v"],
  527 + ["-m", "unittest", "src.test_backend_server.SkillRouteTest", "-k", "audit_log", "-v"],
  528 + ["-m", "unittest", "src.test_backend_server.SkillRouteTest", "-k", "diff", "-v"],
  529 + ["-m", "unittest", "src.test_backend_server.SkillRouteTest", "-k", "rollback", "-v"],
  530 + ["-m", "unittest", "src.test_backend_server.SkillRouteTest.test_full_update_diff_rollback_cycle", "-v"],
  531 + ]
  532 + for cmd in commands:
  533 + result = self._run(cmd)
  534 + self.assertEqual(result.returncode, 0, "command failed: %s\n%s" % (cmd, result.stderr))
  535 +
  536 + def test_t5_2_full_src_suite_is_green(self):
  537 + result = self._run(["-m", "unittest", "discover", "-s", "src"])
  538 + self.assertEqual(result.returncode, 0, "src suite is not green:\n" + result.stderr[-6000:])
  539 +
  540 +
  541 +# ---------------------------------------------------------------------------
  542 +# 6. Delivery must be committed, not left in an uncommitted/worktree diff --
  543 +# T6.1-T6.3
  544 +# ---------------------------------------------------------------------------
  545 +class GitDeliveryStateTest(unittest.TestCase):
  546 + TARGET_FILES = [
  547 + "src/skill_query.py", "src/backend_server.py", "frontend/app.js",
  548 + "src/test_skill_query.py", "src/test_backend_server.py",
  549 + ]
  550 +
  551 + def _git(self, args, cwd):
  552 + return subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True)
  553 +
  554 + def test_t6_1_working_tree_clean_for_target_files(self):
  555 + out = self._git(["status", "--porcelain", "--"] + self.TARGET_FILES, REPO_ROOT)
  556 + self.assertEqual(out.returncode, 0, out.stderr)
  557 + self.assertEqual(
  558 + out.stdout.strip(), "",
  559 + "target files must have zero uncommitted changes once the Unit is done:\n" + out.stdout,
  560 + )
  561 +
  562 + def test_t6_2_commits_exist_and_touch_the_real_contract(self):
  563 + newest_shas = []
  564 + for f in ("src/skill_query.py", "src/backend_server.py", "frontend/app.js"):
  565 + out = self._git(["log", "--oneline", "-1", "--", f], REPO_ROOT)
  566 + self.assertEqual(out.returncode, 0, out.stderr)
  567 + line = out.stdout.strip()
  568 + self.assertTrue(line, "expected at least one commit touching " + f)
  569 + newest_shas.append(line.split()[0])
  570 +
  571 + found_contract_evidence = False
  572 + for sha in newest_shas:
  573 + show = self._git(["show", sha], REPO_ROOT)
  574 + if "SkillPathTraversalError" in show.stdout or "supporting_files" in show.stdout:
  575 + found_contract_evidence = True
  576 + break
  577 + self.assertTrue(
  578 + found_contract_evidence,
  579 + "none of the newest commits touching the 3 core files mention SkillPathTraversalError/"
  580 + "supporting_files -- looks like an unrelated commit merely touched these files, not the real fix",
  581 + )
  582 +
  583 + def test_t6_3_changes_are_visible_from_the_main_checkout(self):
  584 + common_dir = self._git(["rev-parse", "--path-format=absolute", "--git-common-dir"], REPO_ROOT)
  585 + if common_dir.returncode != 0:
  586 + self.skipTest("this git version doesn't support --git-common-dir, cannot resolve the main checkout")
  587 + main_root = os.path.dirname(common_dir.stdout.strip())
  588 +
  589 + if os.path.normpath(main_root) == os.path.normpath(REPO_ROOT):
  590 + self.skipTest("already running from the main checkout -- T6.1/T6.2 already covered this case")
  591 +
  592 + out = self._git(["status", "--porcelain", "--"] + self.TARGET_FILES, main_root)
  593 + self.assertEqual(out.returncode, 0, out.stderr)
  594 + self.assertEqual(out.stdout.strip(), "", "the main checkout must also be clean for the target files")
  595 +
  596 + probe = subprocess.run(
  597 + [sys.executable, "-c",
  598 + "import sys; sys.path.insert(0, sys.argv[1]); from src import skill_query; "
  599 + "print(hasattr(skill_query, 'SkillPathTraversalError'))", main_root],
  600 + capture_output=True, text=True,
  601 + )
  602 + self.assertEqual(
  603 + probe.stdout.strip(), "True",
  604 + "main checkout's src/skill_query.py does not define SkillPathTraversalError -- "
  605 + "the fix may only exist in a worktree, not merged back to main:\n" + probe.stderr,
  606 + )
  607 +
  608 +
  609 +if __name__ == "__main__":
  610 + unittest.main(verbosity=2)
... ...
src/test_skill_content_benchmark.py 0 → 100644
  1 +"""Benchmark acceptance script for "Skill 详情页展示真实内容
  2 +(`v_skill_audit_log` 契约收口)" -- mechanically checks the scenarios from
  3 +Benchmark §1-§6 against src/skill_query.py::get_skill_content(),
  4 +src/backend_server.py's skill routes, frontend/app.js::mountSkillAuditLog,
  5 +and the git delivery state of the fix.
  6 +
  7 +This is an acceptance script, not a permanent addition to the existing
  8 +src/test_skill_query.py / src/test_backend_server.py suites -- it is
  9 +expected to FAIL (via AssertionError or AttributeError on names like
  10 +skill_query.SkillPathTraversalError that don't exist yet) against the
  11 +pre-fix codebase, and to fully PASS once the fix described in the Benchmark
  12 +is implemented and committed.
  13 +
  14 +Run (from repo root, with this file placed at src/test_skill_content_benchmark.py,
  15 +same `python3 -m unittest src.<module>` convention every other src/test_*.py file
  16 +in this repo uses):
  17 + python3 -m unittest src.test_skill_content_benchmark -v
  18 +
  19 +T5.2 (the full `discover -s src` regression run) is expensive (560+ tests)
  20 +and is gated behind RUN_FULL_REGRESSION=1, mirroring this repo's own
  21 +RUN_REAL_AGENT_E2E precedent (src/test_support.py:skip_unless_env) for
  22 +opt-in expensive checks:
  23 + RUN_FULL_REGRESSION=1 python3 -m unittest src.test_skill_content_benchmark.RegressionSuiteTest.test_t5_2_full_src_suite_green -v
  24 +"""
  25 +
  26 +import json
  27 +import os
  28 +import re
  29 +import shutil
  30 +import subprocess
  31 +import sys
  32 +import tempfile
  33 +import textwrap
  34 +import threading
  35 +import unittest
  36 +import http.client
  37 +from unittest import mock
  38 +
  39 +from src import schema, skill_query, skill_update, backend_server
  40 +
  41 +_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
  42 +REPO_ROOT = os.path.dirname(_THIS_DIR)
  43 +APP_JS_PATH = os.path.join(REPO_ROOT, "frontend", "app.js")
  44 +
  45 +SAMPLE_CONTENT = "# skill-1\n\nThis is the real migrated SKILL.md content for skill-1.\n"
  46 +
  47 +
  48 +def _init_git_repo(path):
  49 + subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
  50 +
  51 +
  52 +def _write_file(path, content):
  53 + os.makedirs(os.path.dirname(path), exist_ok=True)
  54 + with open(path, "w", encoding="utf-8") as f:
  55 + f.write(content)
  56 +
  57 +
  58 +def _seed_skill_fixtures(conn, repo_dir):
  59 + """The four (+1) fixture skills described in the Benchmark's own
  60 + "以下测试夹具" preamble: skill-1 (normal), skill-missing-file (DB row,
  61 + no file on disk), skill-evil (whole-path traversal), skill-evil-2 (a
  62 + second, differently-constructed traversal -- T1.5 requires two distinct
  63 + construction styles), skill-with-support (main file + supporting
  64 + files)."""
  65 + conn.execute(
  66 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, "
  67 + "migrated_from_profile, status) VALUES (?,?,?,?,?,?,?)",
  68 + ("skill-1", "coder", "skills/skill-1", "abc123", "migrated_from_hermes", "code", "active"),
  69 + )
  70 + _write_file(os.path.join(repo_dir, "skills", "skill-1", "SKILL.md"), SAMPLE_CONTENT)
  71 +
  72 + conn.execute(
  73 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, "
  74 + "migrated_from_profile, status) VALUES (?,?,?,?,?,?,?)",
  75 + ("skill-missing-file", "coder", "skills/skill-missing-file", "unknown", "new", None, "active"),
  76 + )
  77 + # deliberately no file/directory written on disk for skill-missing-file
  78 +
  79 + conn.execute(
  80 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, "
  81 + "migrated_from_profile, status) VALUES (?,?,?,?,?,?,?)",
  82 + ("skill-evil", "coder", "../../etc/passwd", "unknown", "new", None, "active"),
  83 + )
  84 +
  85 + conn.execute(
  86 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, "
  87 + "migrated_from_profile, status) VALUES (?,?,?,?,?,?,?)",
  88 + ("skill-evil-2", "coder", "skills/../../../tmp/evil", "unknown", "new", None, "active"),
  89 + )
  90 +
  91 + conn.execute(
  92 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, "
  93 + "migrated_from_profile, status) VALUES (?,?,?,?,?,?,?)",
  94 + ("skill-with-support", "coder", "skills/skill-with-support", "unknown", "new", None, "active"),
  95 + )
  96 + _write_file(os.path.join(repo_dir, "skills", "skill-with-support", "SKILL.md"), "support demo\n")
  97 + _write_file(os.path.join(repo_dir, "skills", "skill-with-support", "references", "foo.md"), "foo\n")
  98 + _write_file(os.path.join(repo_dir, "skills", "skill-with-support", "scripts", "bar.py"), "# bar\n")
  99 +
  100 + conn.commit()
  101 +
  102 +
  103 +# ---------------------------------------------------------------------------
  104 +# Section 1 (Benchmark §1): src/skill_query.py::get_skill_content() unit
  105 +# layer -- T1.1-T1.7 + T4.1 (sentinel non-leakage).
  106 +# ---------------------------------------------------------------------------
  107 +
  108 +class GetSkillContentUnitTest(unittest.TestCase):
  109 + def setUp(self):
  110 + self.tmp = tempfile.TemporaryDirectory()
  111 + self.addCleanup(self.tmp.cleanup)
  112 + _init_git_repo(self.tmp.name)
  113 + self.conn = schema.connect(":memory:")
  114 + schema.create_schema(self.conn)
  115 + self.conn.commit()
  116 + _seed_skill_fixtures(self.conn, self.tmp.name)
  117 +
  118 + def tearDown(self):
  119 + self.conn.close()
  120 +
  121 + def test_t1_1_normal_content_all_nine_fields(self):
  122 + result = skill_query.get_skill_content(self.conn, "skill-1", repo_path=self.tmp.name)
  123 + self.assertEqual(
  124 + set(result.keys()),
  125 + {
  126 + "skill_id", "owner_org_node", "file_path", "current_git_commit_sha",
  127 + "source", "migrated_from_profile", "status", "content", "supporting_files",
  128 + },
  129 + )
  130 + self.assertEqual(result["skill_id"], "skill-1")
  131 + self.assertEqual(result["owner_org_node"], "coder")
  132 + self.assertEqual(result["file_path"], "skills/skill-1")
  133 + self.assertEqual(result["current_git_commit_sha"], "abc123")
  134 + self.assertEqual(result["source"], "migrated_from_hermes")
  135 + self.assertEqual(result["migrated_from_profile"], "code")
  136 + self.assertEqual(result["status"], "active")
  137 + self.assertEqual(result["content"], SAMPLE_CONTENT)
  138 + self.assertEqual(result["supporting_files"], [])
  139 +
  140 + def test_t1_2_missing_file_returns_content_none_no_exception(self):
  141 + result = skill_query.get_skill_content(self.conn, "skill-missing-file", repo_path=self.tmp.name)
  142 + self.assertIsNone(result["content"])
  143 + self.assertIsInstance(result["supporting_files"], list)
  144 + self.assertEqual(result["supporting_files"], [])
  145 + self.assertEqual(result["skill_id"], "skill-missing-file")
  146 +
  147 + def test_t1_3_nonexistent_skill_raises_not_found(self):
  148 + with self.assertRaises(skill_query.SkillNotFoundError):
  149 + skill_query.get_skill_content(self.conn, "does-not-exist", repo_path=self.tmp.name)
  150 +
  151 + def test_t1_4_whole_path_traversal_rejected_before_any_open(self):
  152 + with mock.patch("builtins.open") as mock_open:
  153 + with self.assertRaises(skill_query.SkillPathTraversalError):
  154 + skill_query.get_skill_content(self.conn, "skill-evil", repo_path=self.tmp.name)
  155 + self.assertEqual(mock_open.call_count, 0, "path-traversal must be rejected before open() is ever called")
  156 +
  157 + def test_t1_5_dotdot_segment_traversal_rejected_before_any_open(self):
  158 + with mock.patch("builtins.open") as mock_open:
  159 + with self.assertRaises(skill_query.SkillPathTraversalError):
  160 + skill_query.get_skill_content(self.conn, "skill-evil-2", repo_path=self.tmp.name)
  161 + self.assertEqual(mock_open.call_count, 0, "path-traversal must be rejected before open() is ever called")
  162 +
  163 + def test_t1_6_supporting_files_sorted_relative_excludes_main_file(self):
  164 + result = skill_query.get_skill_content(self.conn, "skill-with-support", repo_path=self.tmp.name)
  165 + self.assertEqual(result["supporting_files"], ["references/foo.md", "scripts/bar.py"])
  166 +
  167 + def test_t4_1_sentinel_content_never_leaks_on_traversal(self):
  168 + sentinel_dir = tempfile.mkdtemp()
  169 + self.addCleanup(shutil.rmtree, sentinel_dir, ignore_errors=True)
  170 + sentinel_path = os.path.join(sentinel_dir, "secret.txt")
  171 + with open(sentinel_path, "w", encoding="utf-8") as f:
  172 + f.write("SENTINEL_MARKER_xyz")
  173 +
  174 + rel = os.path.relpath(sentinel_path, os.path.join(self.tmp.name, "skills"))
  175 + self.conn.execute("UPDATE skill SET file_path = ? WHERE skill_id = 'skill-evil'", (rel,))
  176 + self.conn.commit()
  177 +
  178 + try:
  179 + result = skill_query.get_skill_content(self.conn, "skill-evil", repo_path=self.tmp.name)
  180 + except skill_query.SkillPathTraversalError as e:
  181 + self.assertNotIn(
  182 + "SENTINEL_MARKER_xyz", str(e),
  183 + "sentinel content must never appear in the raised exception's message",
  184 + )
  185 + else:
  186 + self.assertNotIn(
  187 + "SENTINEL_MARKER_xyz", json.dumps(result, default=str),
  188 + "sentinel content leaked into a non-exception return value -- traversal was not actually rejected",
  189 + )
  190 +
  191 +
  192 +# ---------------------------------------------------------------------------
  193 +# Section 2 (Benchmark §2): src/backend_server.py HTTP route layer --
  194 +# T2.1-T2.7.
  195 +# ---------------------------------------------------------------------------
  196 +
  197 +class SkillContentRouteTest(unittest.TestCase):
  198 + def setUp(self):
  199 + self.tmp = tempfile.TemporaryDirectory()
  200 + self.addCleanup(self.tmp.cleanup)
  201 + _init_git_repo(self.tmp.name)
  202 + self.server = backend_server.create_server(db_path=":memory:", skill_repo_path=self.tmp.name)
  203 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  204 + self.thread.start()
  205 + self.addCleanup(self._stop_server)
  206 + _seed_skill_fixtures(self.server.conn, self.tmp.name)
  207 +
  208 + def _stop_server(self):
  209 + self.server.shutdown()
  210 + self.server.server_close()
  211 + self.thread.join(timeout=5)
  212 +
  213 + def _get(self, path):
  214 + host, port = self.server.server_address
  215 + conn = http.client.HTTPConnection(host, port, timeout=5)
  216 + conn.request("GET", path)
  217 + resp = conn.getresponse()
  218 + raw = resp.read()
  219 + conn.close()
  220 + try:
  221 + body = json.loads(raw)
  222 + except (json.JSONDecodeError, UnicodeDecodeError):
  223 + body = raw
  224 + return resp.status, body
  225 +
  226 + def _post(self, path, payload):
  227 + host, port = self.server.server_address
  228 + conn = http.client.HTTPConnection(host, port, timeout=5)
  229 + conn.request("POST", path, body=json.dumps(payload).encode("utf-8"))
  230 + resp = conn.getresponse()
  231 + body = json.loads(resp.read())
  232 + conn.close()
  233 + return resp.status, body
  234 +
  235 + def test_t2_1_normal_detail_response(self):
  236 + status, body = self._get("/api/skills/skill-1")
  237 + self.assertEqual(status, 200)
  238 + self.assertEqual(
  239 + set(body.keys()),
  240 + {
  241 + "skill_id", "owner_org_node", "file_path", "current_git_commit_sha",
  242 + "source", "migrated_from_profile", "status", "content", "supporting_files",
  243 + },
  244 + )
  245 + self.assertEqual(body["content"], SAMPLE_CONTENT)
  246 + self.assertEqual(body["supporting_files"], [])
  247 +
  248 + def test_t2_2_nonexistent_returns_404_not_found(self):
  249 + status, body = self._get("/api/skills/does-not-exist")
  250 + self.assertEqual(status, 404)
  251 + self.assertEqual(body, {"error": "NOT_FOUND"})
  252 +
  253 + def test_t2_3_missing_file_returns_200_content_null(self):
  254 + status, body = self._get("/api/skills/skill-missing-file")
  255 + self.assertEqual(status, 200)
  256 + self.assertIsNone(body["content"])
  257 + self.assertNotIn("error", body)
  258 +
  259 + def test_t2_4_path_traversal_returns_400_invalid_skill_id(self):
  260 + status, body = self._get("/api/skills/skill-evil")
  261 + self.assertEqual(status, 400)
  262 + self.assertEqual(body, {"error": "INVALID_SKILL_ID"})
  263 +
  264 + def test_t2_5_audit_and_diff_routes_not_swallowed_by_bare_skill_id_branch(self):
  265 + status, body = self._get("/api/skills/skill-1/audit")
  266 + self.assertEqual(status, 200)
  267 + self.assertIsInstance(body, list, "/audit must still return the iteration-list format, not the detail object")
  268 +
  269 + status, body = self._get("/api/skills/diff")
  270 + self.assertEqual(status, 400)
  271 + self.assertEqual(
  272 + body.get("error"), "INVALID_INPUT",
  273 + "must hit _handle_skill_diff's own validation (INVALID_INPUT), not be misrouted as skill_id='diff' (which would 404 NOT_FOUND)",
  274 + )
  275 +
  276 + def test_t2_6_rollback_regression_unaffected(self):
  277 + skill_update._write_and_commit(self.tmp.name, "skills/skill-1/SKILL.md", SAMPLE_CONTENT, "seed skill")
  278 + self.server.conn.execute(
  279 + "INSERT INTO bug_record (bug_id, source, description, root_cause_category, root_cause_detail, "
  280 + "suggested_route, status) VALUES ('bug-1', 'validator', 'x', 'skill_defect', 'd', 'fast_lane', 'routed')"
  281 + )
  282 + self.server.conn.commit()
  283 + with self.server.conn_lock:
  284 + result = skill_update.update_skill(
  285 + self.server.conn, "skill-1", "bug-1", "v2 content", "improve it", self.tmp.name
  286 + )
  287 + status, body = self._post("/api/skills/skill-1/rollback", {"iteration_id": result["iteration_id"]})
  288 + self.assertEqual(status, 200)
  289 + self.assertIn("new_iteration_id", body)
  290 +
  291 + def test_t2_7_old_content_route_removed(self):
  292 + # real file present on disk -- under the OLD (pre-fix) /content route
  293 + # this would come back 200 with the real content; the fix must not
  294 + # keep that route alive as a compatibility alias.
  295 + skill_update._write_and_commit(self.tmp.name, "skills/skill-1/SKILL.md", SAMPLE_CONTENT, "seed skill")
  296 + status, _body = self._get("/api/skills/skill-1/content")
  297 + self.assertEqual(status, 404, "the old GET /api/skills/:id/content route must be fully removed (falls through to the generic 404)")
  298 +
  299 +
  300 +# ---------------------------------------------------------------------------
  301 +# Section 3 (Benchmark §3): frontend/app.js::mountSkillAuditLog -- T3.1-T3.4,
  302 +# via a bare Node vm.Context sandbox in the style of
  303 +# frontend/test_graph_console_smoke.js (this repo's only JS test precedent;
  304 +# no npm/DOM-testing framework exists here by design).
  305 +# ---------------------------------------------------------------------------
  306 +
  307 +def _build_frontend_smoke_js():
  308 + return textwrap.dedent(r"""
  309 + "use strict";
  310 + const fs = require("fs");
  311 + const vm = require("vm");
  312 + const assert = require("assert");
  313 +
  314 + const APP_JS_PATH = process.argv[2];
  315 + const SAMPLE_CONTENT = "# skill-1\n\nThis is the real migrated SKILL.md content for skill-1.\n";
  316 +
  317 + function makeFakeDiv() {
  318 + let text = "";
  319 + return {
  320 + set textContent(v) { text = v == null ? "" : String(v); },
  321 + get textContent() { return text; },
  322 + get innerHTML() {
  323 + return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
  324 + },
  325 + };
  326 + }
  327 +
  328 + function makeElement() {
  329 + return { innerHTML: "", textContent: "", style: { display: "" } };
  330 + }
  331 +
  332 + function mockResp(status, body) {
  333 + return Promise.resolve({ status: status, json: async () => body });
  334 + }
  335 +
  336 + function sleep(ms) {
  337 + return new Promise((resolve) => setTimeout(resolve, ms));
  338 + }
  339 +
  340 + function unescapeHtml(s) {
  341 + return s.replace(/&quot;/g, "\"").replace(/&gt;/g, ">").replace(/&lt;/g, "<").replace(/&amp;/g, "&");
  342 + }
  343 +
  344 + function preTextContent(html) {
  345 + const m = html.match(/<pre[^>]*>([\s\S]*?)<\/pre>/);
  346 + return m ? unescapeHtml(m[1]) : null;
  347 + }
  348 +
  349 + async function run() {
  350 + const elements = {
  351 + skill_content: makeElement(),
  352 + skill_audit_list: makeElement(),
  353 + skill_audit_error: makeElement(),
  354 + };
  355 + const sandbox = {
  356 + document: {
  357 + createElement: () => makeFakeDiv(),
  358 + getElementById: (id) => elements[id] || null,
  359 + querySelectorAll: () => [],
  360 + addEventListener: () => {},
  361 + },
  362 + window: { location: { protocol: "http:", host: "localhost", hash: "" }, addEventListener: () => {} },
  363 + localStorage: { getItem: () => null, setItem: () => {} },
  364 + setTimeout: setTimeout,
  365 + clearTimeout: clearTimeout,
  366 + console: console,
  367 + fetch: null,
  368 + };
  369 + vm.createContext(sandbox);
  370 + const src = fs.readFileSync(APP_JS_PATH, "utf8");
  371 + new vm.Script(src, { filename: "app.js" }).runInContext(sandbox);
  372 +
  373 + function resetElements() {
  374 + elements.skill_content.innerHTML = "";
  375 + elements.skill_audit_list.innerHTML = "";
  376 + elements.skill_audit_error.innerHTML = "";
  377 + elements.skill_audit_error.textContent = "";
  378 + elements.skill_audit_error.style.display = "";
  379 + }
  380 +
  381 + // ---- T3.1: normal content rendering ----
  382 + resetElements();
  383 + sandbox.fetch = (url) => {
  384 + if (url === "/api/skills/skill-1") {
  385 + return mockResp(200, {
  386 + skill_id: "skill-1", owner_org_node: "coder", file_path: "skills/skill-1",
  387 + current_git_commit_sha: "abc123", source: "migrated_from_hermes",
  388 + migrated_from_profile: "code", status: "active",
  389 + content: SAMPLE_CONTENT, supporting_files: [],
  390 + });
  391 + }
  392 + if (url === "/api/skills/skill-1/audit") return mockResp(200, []);
  393 + throw new Error("unexpected fetch url: " + url);
  394 + };
  395 + sandbox.mountSkillAuditLog("skill-1");
  396 + await sleep(50);
  397 + assert.strictEqual(preTextContent(elements.skill_content.innerHTML), SAMPLE_CONTENT, "T3.1: pre textContent must equal the real file content exactly");
  398 + assert.ok(elements.skill_content.innerHTML.includes('class="hint"'), "T3.1: a hint element must be present");
  399 + assert.ok(elements.skill_content.innerHTML.includes("无支持文件"), "T3.1: empty supporting_files must render the '无支持文件' hint text");
  400 + console.log("T3.1 OK");
  401 +
  402 + // ---- T3.2: content null ----
  403 + resetElements();
  404 + sandbox.fetch = (url) => {
  405 + if (url === "/api/skills/skill-2") {
  406 + return mockResp(200, {
  407 + skill_id: "skill-2", owner_org_node: "coder", file_path: "skills/skill-2",
  408 + current_git_commit_sha: "unknown", source: "new",
  409 + migrated_from_profile: null, status: "active",
  410 + content: null, supporting_files: [],
  411 + });
  412 + }
  413 + if (url === "/api/skills/skill-2/audit") return mockResp(200, []);
  414 + throw new Error("unexpected fetch url: " + url);
  415 + };
  416 + sandbox.mountSkillAuditLog("skill-2");
  417 + await sleep(50);
  418 + assert.strictEqual(preTextContent(elements.skill_content.innerHTML), "(内容文件不可用)", "T3.2: null content must render the placeholder text verbatim, with no thrown exception");
  419 + console.log("T3.2 OK");
  420 +
  421 + // ---- T3.3: skill not found -- shared error banner, both panels cleared ----
  422 + resetElements();
  423 + sandbox.fetch = (url) => {
  424 + if (url === "/api/skills/skill-x") return mockResp(404, { error: "NOT_FOUND" });
  425 + if (url === "/api/skills/skill-x/audit") return mockResp(404, { error: "NOT_FOUND" });
  426 + throw new Error("unexpected fetch url: " + url);
  427 + };
  428 + sandbox.mountSkillAuditLog("skill-x");
  429 + await sleep(50);
  430 + assert.strictEqual(elements.skill_content.innerHTML, "", "T3.3: content panel must be cleared (empty string) when the skill doesn't exist");
  431 + assert.strictEqual(elements.skill_audit_list.innerHTML, "", "T3.3: audit panel must be cleared (empty string) when the skill doesn't exist");
  432 + assert.strictEqual(elements.skill_audit_error.style.display, "block", "T3.3: shared error banner must be visible");
  433 + assert.ok(elements.skill_audit_error.textContent && elements.skill_audit_error.textContent.length > 0, "T3.3: error banner must carry non-empty text");
  434 + console.log("T3.3 OK");
  435 +
  436 + // ---- T3.4: supporting files listed, in order, no old field-name reliance ----
  437 + resetElements();
  438 + sandbox.fetch = (url) => {
  439 + if (url === "/api/skills/skill-3") {
  440 + return mockResp(200, {
  441 + skill_id: "skill-3", owner_org_node: "coder", file_path: "skills/skill-3",
  442 + current_git_commit_sha: "unknown", source: "new",
  443 + migrated_from_profile: null, status: "active",
  444 + content: "content\n", supporting_files: ["references/foo.md", "scripts/bar.py"],
  445 + });
  446 + }
  447 + if (url === "/api/skills/skill-3/audit") return mockResp(200, []);
  448 + throw new Error("unexpected fetch url: " + url);
  449 + };
  450 + sandbox.mountSkillAuditLog("skill-3");
  451 + await sleep(50);
  452 + assert.ok(elements.skill_content.innerHTML.includes("references/foo.md"), "T3.4: hint must list the first supporting file");
  453 + assert.ok(elements.skill_content.innerHTML.includes("scripts/bar.py"), "T3.4: hint must list the second supporting file");
  454 + console.log("T3.4 OK");
  455 +
  456 + console.log("ALL_FRONTEND_SMOKE_CHECKS_PASSED");
  457 + }
  458 +
  459 + run().catch((err) => {
  460 + console.error(String((err && err.stack) || err));
  461 + process.exit(1);
  462 + });
  463 + """)
  464 +
  465 +
  466 +class FrontendSkillDetailSmokeTest(unittest.TestCase):
  467 + def test_mount_skill_audit_log_smoke(self):
  468 + if shutil.which("node") is None:
  469 + self.skipTest("node is not on PATH -- cannot run the frontend vm-sandbox smoke test")
  470 + fd, script_path = tempfile.mkstemp(suffix=".js")
  471 + os.close(fd)
  472 + with open(script_path, "w", encoding="utf-8") as f:
  473 + f.write(_build_frontend_smoke_js())
  474 + self.addCleanup(os.remove, script_path)
  475 +
  476 + proc = subprocess.run(
  477 + ["node", script_path, APP_JS_PATH],
  478 + capture_output=True, text=True, timeout=30,
  479 + )
  480 + self.assertEqual(
  481 + proc.returncode, 0,
  482 + "frontend/app.js::mountSkillAuditLog smoke test failed:\nstdout:\n" + proc.stdout + "\nstderr:\n" + proc.stderr,
  483 + )
  484 + self.assertIn("ALL_FRONTEND_SMOKE_CHECKS_PASSED", proc.stdout)
  485 +
  486 +
  487 +# ---------------------------------------------------------------------------
  488 +# T1.7 / T3.5: static structural scans -- old symbols/field names/routes
  489 +# must not remain anywhere in the codebase after the fix. Pure-Python
  490 +# recursive search (not a shelled-out `grep -P`) so this runs identically on
  491 +# BSD grep (macOS default) and GNU grep (Linux) dev machines.
  492 +# ---------------------------------------------------------------------------
  493 +
  494 +_THIS_FILE_NAME = os.path.basename(os.path.abspath(__file__))
  495 +
  496 +
  497 +def _grep_py_tree(subdir, pattern):
  498 + """Excludes this benchmark script's own file: it necessarily *names*
  499 + SkillContentMissingError in its docstrings/messages for documentation
  500 + purposes, which would otherwise make T1.7 permanently unsatisfiable even
  501 + after every real production/test reference is gone."""
  502 + regex = re.compile(pattern)
  503 + matches = []
  504 + root = os.path.join(REPO_ROOT, subdir)
  505 + for dirpath, dirnames, filenames in os.walk(root):
  506 + dirnames[:] = [d for d in dirnames if d != ".git"]
  507 + for name in filenames:
  508 + if not name.endswith(".py") or name == _THIS_FILE_NAME:
  509 + continue
  510 + full = os.path.join(dirpath, name)
  511 + with open(full, "r", encoding="utf-8", errors="replace") as f:
  512 + for lineno, line in enumerate(f, start=1):
  513 + if regex.search(line):
  514 + matches.append("%s:%d: %s" % (full, lineno, line.rstrip("\n")))
  515 + return matches
  516 +
  517 +
  518 +def _grep_file(path, pattern):
  519 + regex = re.compile(pattern)
  520 + matches = []
  521 + with open(path, "r", encoding="utf-8", errors="replace") as f:
  522 + for lineno, line in enumerate(f, start=1):
  523 + if regex.search(line):
  524 + matches.append("%s:%d: %s" % (path, lineno, line.rstrip("\n")))
  525 + return matches
  526 +
  527 +
  528 +class StaticContractScanTest(unittest.TestCase):
  529 + def test_t1_7_skill_content_missing_error_removed_from_src(self):
  530 + matches = _grep_py_tree("src", r"SkillContentMissingError")
  531 + self.assertEqual(matches, [], "SkillContentMissingError must no longer be defined or referenced anywhere under src/:\n" + "\n".join(matches))
  532 +
  533 + def test_t3_5_no_legacy_support_files_field_name_in_app_js(self):
  534 + matches = _grep_file(APP_JS_PATH, r"(?<!ing_)support_files\b")
  535 + self.assertEqual(matches, [], "legacy 'support_files' field name must not remain in frontend/app.js (must be 'supporting_files'):\n" + "\n".join(matches))
  536 +
  537 + def test_t3_5_no_legacy_content_route_suffix_in_app_js(self):
  538 + matches = _grep_file(APP_JS_PATH, r'/content"')
  539 + self.assertEqual(matches, [], "frontend/app.js must no longer fetch the removed .../content route suffix:\n" + "\n".join(matches))
  540 +
  541 +
  542 +# ---------------------------------------------------------------------------
  543 +# Section 5 (Benchmark §5): full regression -- T5.1 (targeted pre-existing
  544 +# tests, run every time) / T5.2 (full `discover -s src`, gated -- expensive).
  545 +# ---------------------------------------------------------------------------
  546 +
  547 +class RegressionSuiteTest(unittest.TestCase):
  548 + _T5_1_COMMANDS = [
  549 + ["src.test_skill_query.ListSkillsTest"],
  550 + ["src.test_skill_query.GetSkillAuditLogTest"],
  551 + ["src.test_skill_query.GetDiffTest"],
  552 + ["src.test_backend_server.SkillRouteTest.test_list_returns_seeded_skill"],
  553 + ["src.test_backend_server.SkillRouteTest", "-k", "audit_log"],
  554 + ["src.test_backend_server.SkillRouteTest", "-k", "diff"],
  555 + ["src.test_backend_server.SkillRouteTest", "-k", "rollback"],
  556 + ["src.test_backend_server.SkillRouteTest.test_full_update_diff_rollback_cycle"],
  557 + ]
  558 +
  559 + def test_t5_1_existing_skill_tests_still_pass(self):
  560 + for extra_args in self._T5_1_COMMANDS:
  561 + with self.subTest(args=extra_args):
  562 + proc = subprocess.run(
  563 + [sys.executable, "-m", "unittest"] + extra_args,
  564 + cwd=REPO_ROOT, capture_output=True, text=True, timeout=120,
  565 + )
  566 + self.assertEqual(
  567 + proc.returncode, 0,
  568 + "regression command failed: unittest " + " ".join(extra_args) + "\n" + proc.stdout + proc.stderr,
  569 + )
  570 +
  571 + @unittest.skipUnless(
  572 + os.environ.get("RUN_FULL_REGRESSION") == "1",
  573 + "full `discover -s src` (560+ tests) is expensive -- set RUN_FULL_REGRESSION=1 to actually run T5.2",
  574 + )
  575 + def test_t5_2_full_src_suite_green(self):
  576 + proc = subprocess.run(
  577 + [sys.executable, "-m", "unittest", "discover", "-s", "src"],
  578 + cwd=REPO_ROOT, capture_output=True, text=True, timeout=1800,
  579 + )
  580 + tail = (proc.stdout[-4000:] + proc.stderr[-4000:])
  581 + self.assertEqual(proc.returncode, 0, "python3 -m unittest discover -s src failed:\n" + tail)
  582 +
  583 +
  584 +# ---------------------------------------------------------------------------
  585 +# Section 6 (Benchmark §6): delivery must be committed, not left as
  586 +# uncommitted worktree diff -- T6.1/T6.2. (T6.3's "re-run everything from the
  587 +# main checkout, not just a worktree" step is inherently a manual re-run of
  588 +# this same script from a different cwd -- REPO_ROOT here is always whatever
  589 +# checkout actually invoked the script.)
  590 +# ---------------------------------------------------------------------------
  591 +
  592 +class GitDeliveryStateTest(unittest.TestCase):
  593 + _TARGET_FILES = [
  594 + "src/skill_query.py", "src/backend_server.py", "frontend/app.js",
  595 + "src/test_skill_query.py", "src/test_backend_server.py",
  596 + ]
  597 + _CORE_FILES = ["src/skill_query.py", "src/backend_server.py", "frontend/app.js"]
  598 + _INVESTIGATION_DATE = "2026-08-13"
  599 +
  600 + def test_t6_1_no_uncommitted_changes_in_target_files(self):
  601 + proc = subprocess.run(
  602 + ["git", "status", "--porcelain", "--"] + self._TARGET_FILES,
  603 + cwd=REPO_ROOT, capture_output=True, text=True, check=True,
  604 + )
  605 + self.assertEqual(
  606 + proc.stdout.strip(), "",
  607 + "these files must be fully committed, not left as uncommitted working-tree changes "
  608 + "(running tests green is not enough -- see Benchmark T6.1):\n" + proc.stdout,
  609 + )
  610 +
  611 + def test_t6_2_recent_commits_touch_core_contract_files(self):
  612 + newest_shas = []
  613 + for path in self._CORE_FILES:
  614 + proc = subprocess.run(
  615 + ["git", "log", "--format=%H %ci", "-1", "--", path],
  616 + cwd=REPO_ROOT, capture_output=True, text=True, check=True,
  617 + )
  618 + line = proc.stdout.strip()
  619 + self.assertTrue(line, path + " has no commit history at all")
  620 + sha, _, date_part = line.partition(" ")
  621 + self.assertGreater(
  622 + date_part[:10], self._INVESTIGATION_DATE,
  623 + path + "'s latest commit (" + date_part[:10] + ") must be newer than the task's investigation date -- looks like no new commit landed",
  624 + )
  625 + newest_shas.append(sha)
  626 +
  627 + found_marker = False
  628 + for sha in newest_shas:
  629 + proc = subprocess.run(
  630 + ["git", "show", sha], cwd=REPO_ROOT, capture_output=True, text=True, check=True,
  631 + )
  632 + if "SkillPathTraversalError" in proc.stdout or "supporting_files" in proc.stdout:
  633 + found_marker = True
  634 + break
  635 + self.assertTrue(
  636 + found_marker,
  637 + "none of the latest commits touching skill_query.py/backend_server.py/app.js contain the "
  638 + "new contract markers (SkillPathTraversalError / supporting_files) -- looks like an unrelated "
  639 + "commit merely happened to touch these files, not the real fix",
  640 + )
  641 +
  642 +
  643 +if __name__ == "__main__":
  644 + unittest.main()
... ...