0xray 2.1.2 → 2.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/.opencode/codex.codex +1 -1
  2. package/.opencode/commands/dependency-audit.md +3 -3
  3. package/.opencode/enforcer-config.json +2 -2
  4. package/AGENTS.md +2 -1
  5. package/README.md +12 -11
  6. package/dist/AGENTS.md +2 -1
  7. package/dist/CHANGELOG.md +38 -0
  8. package/dist/README.md +12 -11
  9. package/dist/agents/code-reviewer.js +1 -1
  10. package/dist/analytics/routing-refiner.js +1 -1
  11. package/dist/cli/index.js +11 -1
  12. package/dist/cli/server.js +3 -3
  13. package/dist/core/activity-logger.d.ts +2 -2
  14. package/dist/core/activity-logger.js +4 -4
  15. package/dist/core/boot-orchestrator.d.ts +1 -1
  16. package/dist/core/boot-orchestrator.js +13 -28
  17. package/dist/core/bridge.mjs +3 -3
  18. package/dist/core/codex-formatter.js +2 -2
  19. package/dist/core/codex-injector.d.ts +0 -1
  20. package/dist/core/codex-injector.js +2 -3
  21. package/dist/core/config-loader.d.ts +1 -1
  22. package/dist/core/config-loader.js +1 -1
  23. package/dist/core/config-paths.d.ts +0 -2
  24. package/dist/core/config-paths.js +7 -8
  25. package/dist/core/context-loader.d.ts +1 -1
  26. package/dist/core/context-loader.js +1 -1
  27. package/dist/core/errors.d.ts +3 -0
  28. package/dist/core/errors.js +10 -0
  29. package/dist/core/features-config.js +1 -1
  30. package/dist/core/framework-logger.d.ts +3 -3
  31. package/dist/core/framework-logger.js +17 -9
  32. package/dist/core/index.d.ts +2 -2
  33. package/dist/core/index.js +4 -2
  34. package/dist/core/logging-config.d.ts +2 -1
  35. package/dist/core/logging-config.js +7 -7
  36. package/dist/enforcement/loaders/codex-loader.js +1 -1
  37. package/dist/execution/opencode-cli-invoker.js +5 -5
  38. package/dist/governance/governance-service.js +1 -1
  39. package/dist/index.d.ts +3 -3
  40. package/dist/index.js +3 -3
  41. package/dist/inference/inference-cycle.d.ts +1 -1
  42. package/dist/inference/inference-cycle.js +10 -10
  43. package/dist/integrations/base/Integration.js +1 -1
  44. package/dist/integrations/base/registry.js +19 -19
  45. package/dist/integrations/grok/grok-cli.js +17 -17
  46. package/dist/integrations/grok/hooks/pre-tool-use.js +1 -1
  47. package/dist/integrations/hermes-agent/bridge.mjs +1 -1
  48. package/dist/integrations/openclaw/api-server.d.ts +0 -1
  49. package/dist/integrations/openclaw/api-server.js +7 -10
  50. package/dist/integrations/openclaw/client.d.ts +0 -1
  51. package/dist/integrations/openclaw/client.js +22 -24
  52. package/dist/integrations/openclaw/hooks/xray-hooks.d.ts +0 -1
  53. package/dist/integrations/openclaw/hooks/xray-hooks.js +17 -18
  54. package/dist/integrations/plugins/plugin-registry.js +5 -5
  55. package/dist/mcps/architect-tools.server.d.ts +2 -4
  56. package/dist/mcps/architect-tools.server.js +112 -195
  57. package/dist/mcps/auto-format.server.d.ts +2 -4
  58. package/dist/mcps/auto-format.server.js +49 -95
  59. package/dist/mcps/boot-orchestrator.server.d.ts +2 -4
  60. package/dist/mcps/boot-orchestrator.server.js +73 -105
  61. package/dist/mcps/config/server-config-registry.js +3 -3
  62. package/dist/mcps/enforcer-tools.server.d.ts +2 -4
  63. package/dist/mcps/enforcer-tools.server.js +202 -285
  64. package/dist/mcps/estimation.server.d.ts +2 -4
  65. package/dist/mcps/estimation.server.js +63 -107
  66. package/dist/mcps/framework-compliance-audit.server.d.ts +2 -4
  67. package/dist/mcps/framework-compliance-audit.server.js +53 -82
  68. package/dist/mcps/framework-help.server.d.ts +2 -4
  69. package/dist/mcps/framework-help.server.js +63 -101
  70. package/dist/mcps/governance.server.js +2 -2
  71. package/dist/mcps/knowledge-skills/api-design.server.d.ts +2 -4
  72. package/dist/mcps/knowledge-skills/api-design.server.js +35 -67
  73. package/dist/mcps/knowledge-skills/architecture-patterns.server.d.ts +2 -10
  74. package/dist/mcps/knowledge-skills/architecture-patterns.server.js +35 -74
  75. package/dist/mcps/knowledge-skills/bug-triage-specialist.server.d.ts +2 -4
  76. package/dist/mcps/knowledge-skills/bug-triage-specialist.server.js +143 -162
  77. package/dist/mcps/knowledge-skills/code-analyzer.server.d.ts +3 -4
  78. package/dist/mcps/knowledge-skills/code-analyzer.server.js +20 -45
  79. package/dist/mcps/knowledge-skills/code-review.server.d.ts +2 -4
  80. package/dist/mcps/knowledge-skills/code-review.server.js +109 -143
  81. package/dist/mcps/knowledge-skills/content-creator.server.d.ts +2 -4
  82. package/dist/mcps/knowledge-skills/content-creator.server.js +205 -226
  83. package/dist/mcps/knowledge-skills/database-design.server.d.ts +2 -4
  84. package/dist/mcps/knowledge-skills/database-design.server.js +117 -151
  85. package/dist/mcps/knowledge-skills/devops-deployment.server.d.ts +2 -4
  86. package/dist/mcps/knowledge-skills/devops-deployment.server.js +71 -160
  87. package/dist/mcps/knowledge-skills/git-workflow.server.d.ts +2 -4
  88. package/dist/mcps/knowledge-skills/git-workflow.server.js +36 -68
  89. package/dist/mcps/knowledge-skills/growth-strategist.server.d.ts +2 -4
  90. package/dist/mcps/knowledge-skills/growth-strategist.server.js +303 -324
  91. package/dist/mcps/knowledge-skills/log-monitor.server.d.ts +2 -4
  92. package/dist/mcps/knowledge-skills/log-monitor.server.js +141 -160
  93. package/dist/mcps/knowledge-skills/mobile-development.server.d.ts +2 -4
  94. package/dist/mcps/knowledge-skills/mobile-development.server.js +92 -209
  95. package/dist/mcps/knowledge-skills/multimodal-looker.server.d.ts +2 -4
  96. package/dist/mcps/knowledge-skills/multimodal-looker.server.js +123 -159
  97. package/dist/mcps/knowledge-skills/performance-optimization.server.d.ts +2 -5
  98. package/dist/mcps/knowledge-skills/performance-optimization.server.js +155 -296
  99. package/dist/mcps/knowledge-skills/project-analysis.server.d.ts +2 -4
  100. package/dist/mcps/knowledge-skills/project-analysis.server.js +75 -226
  101. package/dist/mcps/knowledge-skills/refactoring-strategies.server.d.ts +2 -4
  102. package/dist/mcps/knowledge-skills/refactoring-strategies.server.js +63 -156
  103. package/dist/mcps/knowledge-skills/security-audit.server.d.ts +2 -4
  104. package/dist/mcps/knowledge-skills/security-audit.server.js +102 -136
  105. package/dist/mcps/knowledge-skills/seo-consultant.server.d.ts +2 -4
  106. package/dist/mcps/knowledge-skills/seo-consultant.server.js +80 -203
  107. package/dist/mcps/knowledge-skills/session-management.server.d.ts +2 -4
  108. package/dist/mcps/knowledge-skills/session-management.server.js +50 -203
  109. package/dist/mcps/knowledge-skills/skill-invocation.server.d.ts +2 -4
  110. package/dist/mcps/knowledge-skills/skill-invocation.server.js +168 -347
  111. package/dist/mcps/knowledge-skills/strategist.server.d.ts +2 -11
  112. package/dist/mcps/knowledge-skills/strategist.server.js +72 -122
  113. package/dist/mcps/knowledge-skills/tech-writer.server.d.ts +2 -4
  114. package/dist/mcps/knowledge-skills/tech-writer.server.js +87 -300
  115. package/dist/mcps/knowledge-skills/testing-best-practices.server.d.ts +2 -4
  116. package/dist/mcps/knowledge-skills/testing-best-practices.server.js +147 -182
  117. package/dist/mcps/knowledge-skills/testing-strategy.server.d.ts +2 -4
  118. package/dist/mcps/knowledge-skills/testing-strategy.server.js +78 -153
  119. package/dist/mcps/knowledge-skills/ui-ux-design.server.d.ts +2 -5
  120. package/dist/mcps/knowledge-skills/ui-ux-design.server.js +90 -399
  121. package/dist/mcps/lint.server.d.ts +2 -4
  122. package/dist/mcps/lint.server.js +51 -92
  123. package/dist/mcps/mcp-client.js +2 -2
  124. package/dist/mcps/model-health-check.server.d.ts +2 -4
  125. package/dist/mcps/model-health-check.server.js +32 -60
  126. package/dist/mcps/performance-analysis.server.d.ts +2 -4
  127. package/dist/mcps/performance-analysis.server.js +57 -88
  128. package/dist/mcps/processor-pipeline.server.d.ts +2 -4
  129. package/dist/mcps/processor-pipeline.server.js +69 -100
  130. package/dist/mcps/registry.json +1 -1
  131. package/dist/mcps/researcher.server.d.ts +3 -5
  132. package/dist/mcps/researcher.server.js +81 -154
  133. package/dist/mcps/security-scan.server.d.ts +2 -4
  134. package/dist/mcps/security-scan.server.js +54 -96
  135. package/dist/mcps/shared/knowledge-skill-base.d.ts +14 -0
  136. package/dist/mcps/shared/knowledge-skill-base.js +45 -0
  137. package/dist/{security → mcps/shared}/security-scanner.js +1 -1
  138. package/dist/mcps/state-manager.server.d.ts +2 -4
  139. package/dist/mcps/state-manager.server.js +115 -160
  140. package/dist/orchestrator/orchestrator.d.ts +1 -1
  141. package/dist/orchestrator/orchestrator.js +1 -1
  142. package/dist/orchestrator/universal-registry-bridge.js +1 -1
  143. package/dist/plugin/xray-codex-injection.d.ts +1 -1
  144. package/dist/plugin/xray-codex-injection.js +1 -1
  145. package/dist/postprocessor/PostProcessor.d.ts +4 -44
  146. package/dist/postprocessor/PostProcessor.js +39 -553
  147. package/dist/postprocessor/analysis/CodeChangeAnalyzer.d.ts +11 -0
  148. package/dist/postprocessor/analysis/CodeChangeAnalyzer.js +50 -0
  149. package/dist/postprocessor/compliance/ArchitecturalComplianceChecker.d.ts +11 -0
  150. package/dist/postprocessor/compliance/ArchitecturalComplianceChecker.js +356 -0
  151. package/dist/postprocessor/config/ProcessorConfigLoader.d.ts +44 -0
  152. package/dist/postprocessor/config/ProcessorConfigLoader.js +21 -0
  153. package/dist/postprocessor/reporting/PostProcessorReporter.d.ts +19 -0
  154. package/dist/postprocessor/reporting/PostProcessorReporter.js +96 -0
  155. package/dist/postprocessor/triggers/GitHookTrigger.js +11 -11
  156. package/dist/processors/implementations/refactoring-logging-processor-wrapper.d.ts +32 -0
  157. package/dist/processors/implementations/refactoring-logging-processor-wrapper.js +95 -1
  158. package/dist/processors/processor-manager.js +346 -314
  159. package/dist/reporting/report-formatter.js +1 -1
  160. package/dist/security/security-hardener.d.ts +69 -2
  161. package/dist/security/security-hardener.js +129 -1
  162. package/dist/skills/registry.json +1 -1
  163. package/dist/state/index.d.ts +3 -5
  164. package/dist/state/index.js +1 -7
  165. package/dist/state/state-manager.d.ts +1 -1
  166. package/dist/state/state-manager.js +2 -3
  167. package/package.json +13 -10
  168. package/scripts/node/universal-version-manager.js +11 -11
  169. package/src/mcps/architect-tools.server.ts +112 -215
  170. package/src/mcps/auto-format.server.ts +50 -110
  171. package/src/mcps/boot-orchestrator.server.ts +75 -121
  172. package/src/mcps/config/__tests__/server-config-registry.test.ts +21 -12
  173. package/src/mcps/config/server-config-registry.ts +3 -3
  174. package/src/mcps/enforcer-tools.server.ts +212 -310
  175. package/src/mcps/estimation.server.ts +62 -122
  176. package/src/mcps/framework-compliance-audit.server.ts +52 -97
  177. package/src/mcps/framework-help.server.ts +64 -114
  178. package/src/mcps/governance.server.ts +2 -2
  179. package/src/mcps/knowledge-skills/api-design.server.ts +32 -77
  180. package/src/mcps/knowledge-skills/architecture-patterns.server.ts +31 -87
  181. package/src/mcps/knowledge-skills/bug-triage-specialist.server.ts +165 -193
  182. package/src/mcps/knowledge-skills/code-analyzer.server.ts +20 -55
  183. package/src/mcps/knowledge-skills/code-review.server.ts +114 -161
  184. package/src/mcps/knowledge-skills/content-creator.server.ts +218 -255
  185. package/src/mcps/knowledge-skills/database-design.server.ts +118 -165
  186. package/src/mcps/knowledge-skills/devops-deployment.server.ts +67 -172
  187. package/src/mcps/knowledge-skills/git-workflow.server.ts +32 -77
  188. package/src/mcps/knowledge-skills/growth-strategist.server.ts +324 -361
  189. package/src/mcps/knowledge-skills/log-monitor.server.ts +160 -187
  190. package/src/mcps/knowledge-skills/mobile-development.server.ts +89 -223
  191. package/src/mcps/knowledge-skills/multimodal-looker.server.ts +128 -175
  192. package/src/mcps/knowledge-skills/performance-optimization.server.ts +156 -329
  193. package/src/mcps/knowledge-skills/project-analysis.server.ts +72 -248
  194. package/src/mcps/knowledge-skills/refactoring-strategies.server.ts +59 -171
  195. package/src/mcps/knowledge-skills/security-audit.server.ts +104 -151
  196. package/src/mcps/knowledge-skills/seo-consultant.server.ts +80 -220
  197. package/src/mcps/knowledge-skills/session-management.server.ts +51 -232
  198. package/src/mcps/knowledge-skills/skill-invocation.server.ts +165 -372
  199. package/src/mcps/knowledge-skills/strategist.server.ts +72 -143
  200. package/src/mcps/knowledge-skills/tech-writer.server.ts +85 -350
  201. package/src/mcps/knowledge-skills/testing-best-practices.server.ts +146 -195
  202. package/src/mcps/knowledge-skills/testing-strategy.server.ts +75 -161
  203. package/src/mcps/knowledge-skills/ui-ux-design.server.ts +93 -487
  204. package/src/mcps/lint.server.ts +53 -107
  205. package/src/mcps/mcp-client.ts +2 -2
  206. package/src/mcps/model-health-check.server.ts +34 -71
  207. package/src/mcps/performance-analysis.server.ts +60 -104
  208. package/src/mcps/processor-pipeline.server.ts +72 -110
  209. package/src/mcps/registry.json +1 -1
  210. package/src/mcps/researcher.server.ts +88 -177
  211. package/src/mcps/security-scan.server.ts +55 -104
  212. package/src/mcps/shared/knowledge-skill-base.ts +62 -0
  213. package/src/mcps/shared/prompt-security-validator.ts +199 -0
  214. package/src/mcps/shared/security-scanner.ts +599 -0
  215. package/src/mcps/state-manager.server.ts +117 -175
  216. package/src/opencode/codex.codex +1 -1
  217. package/src/opencode/commands/dependency-audit.md +3 -3
  218. package/src/opencode/enforcer-config.json +2 -2
  219. package/src/skills/registry.json +1 -1
  220. package/xray/codex.json +1 -1
  221. package/xray/config.json +1 -1
  222. package/xray/features.json +1 -1
  223. package/xray/integrations.json +3 -3
  224. package/dist/integrations/hermes-agent/__pycache__/__init__.cpython-313.pyc +0 -0
  225. package/dist/integrations/hermes-agent/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc +0 -0
  226. package/dist/integrations/hermes-agent/__pycache__/schemas.cpython-313.pyc +0 -0
  227. package/dist/integrations/hermes-agent/__pycache__/test_plugin.cpython-313-pytest-9.0.2.pyc +0 -0
  228. package/dist/integrations/hermes-agent/__pycache__/test_plugin.cpython-313.pyc +0 -0
  229. package/dist/integrations/hermes-agent/__pycache__/tools.cpython-313.pyc +0 -0
  230. package/dist/integrations/hermes-agent/conftest.py +0 -14
  231. package/dist/integrations/hermes-agent/test_plugin.py +0 -1103
  232. package/dist/processors/implementations/refactoring-logging-processor.d.ts +0 -31
  233. package/dist/processors/implementations/refactoring-logging-processor.js +0 -96
  234. package/dist/processors/implementations/session-capture-processor.d.ts +0 -14
  235. package/dist/processors/implementations/session-capture-processor.js +0 -37
  236. package/dist/scripts/activate-kernel-pipeline.d.ts +0 -7
  237. package/dist/scripts/activate-kernel-pipeline.js +0 -101
  238. package/dist/security/index.d.ts +0 -13
  239. package/dist/security/index.js +0 -13
  240. package/dist/security/security-agent-coordinator.d.ts +0 -72
  241. package/dist/security/security-agent-coordinator.js +0 -204
  242. package/dist/security/security-auditor.d.ts +0 -56
  243. package/dist/security/security-auditor.js +0 -584
  244. package/dist/security/security-hardening-system.d.ts +0 -239
  245. package/dist/security/security-hardening-system.js +0 -727
  246. package/dist/security/security-orchestration-layer.d.ts +0 -119
  247. package/dist/security/security-orchestration-layer.js +0 -496
  248. /package/dist/{security → mcps/shared}/prompt-security-validator.d.ts +0 -0
  249. /package/dist/{security → mcps/shared}/prompt-security-validator.js +0 -0
  250. /package/dist/{security → mcps/shared}/security-scanner.d.ts +0 -0
@@ -1,1103 +0,0 @@
1
- """Comprehensive tests for the 0xRay Hermes Plugin v2.
2
-
3
- Tests all 3 tools, both hooks, the slash command, bridge integration,
4
- logging to disk, and the full register() wiring.
5
-
6
- v2 changes from v1:
7
- - Hooks now pipe through Node.js bridge for real framework integration
8
- - Tool events logged to disk (activity.log, plugin-tool-events.log)
9
- - Tools use bridge first, fall back to CLI
10
- - Session stats track quality gate runs, processor runs, bridge calls
11
- - No more in-memory _TOOL_LOG — everything persists to disk
12
- """
13
-
14
- import json
15
- import subprocess
16
- import os
17
- import sys
18
- import tempfile
19
- import unittest
20
- from pathlib import Path
21
- from unittest.mock import patch, MagicMock, call
22
- import logging
23
- import importlib
24
- import types
25
-
26
- # ── Path setup ────────────────────────────────────────────────
27
-
28
- PLUGIN_DIR = str(Path(__file__).resolve().parent)
29
- sys.path.insert(0, PLUGIN_DIR)
30
-
31
- # Force reimport
32
- for mod in list(sys.modules):
33
- if "xray" in mod and ("schemas" in mod or "tools" in mod or "plugin" in mod):
34
- del sys.modules[mod]
35
-
36
- schemas = importlib.import_module("schemas")
37
- tools_mod = importlib.import_module("tools")
38
-
39
- # Create a fake package for __init__.py execution
40
- pkg = types.ModuleType("xray_hermes_pkg")
41
- pkg.__path__ = [PLUGIN_DIR]
42
- pkg.__dict__["schemas"] = schemas
43
- pkg.__dict__["tools"] = tools_mod
44
- sys.modules["xray_hermes_pkg"] = pkg
45
-
46
- init_path = os.path.join(PLUGIN_DIR, "__init__.py")
47
- with open(init_path) as f:
48
- init_code = f.read()
49
- init_code = init_code.replace("from . import schemas, tools", "import schemas, tools")
50
- # Provide __file__ since exec() loses it
51
- pkg.__dict__["__file__"] = init_path
52
- exec(compile(init_code, init_path, "exec"), pkg.__dict__)
53
-
54
- pi = pkg # plugin init module
55
-
56
-
57
- class TestSchemas(unittest.TestCase):
58
- def test_validate_schema_has_required_fields(self):
59
- s = schemas.XRAY_VALIDATE
60
- self.assertEqual(s["name"], "xray_validate")
61
- params = s["parameters"]
62
- self.assertEqual(params["type"], "object")
63
- self.assertIn("files", params["properties"])
64
- self.assertIn("operation", params["properties"])
65
- self.assertIn("files", params["required"])
66
- self.assertEqual(params["properties"]["files"]["type"], "array")
67
-
68
- def test_codex_check_schema(self):
69
- s = schemas.XRAY_CODEX_CHECK
70
- self.assertEqual(s["name"], "xray_codex_check")
71
- fa = s["parameters"]["properties"]["focus_areas"]
72
- self.assertIn("enum", fa["items"])
73
- self.assertIn("error-handling", fa["items"]["enum"])
74
-
75
- def test_health_schema_no_required(self):
76
- s = schemas.XRAY_HEALTH
77
- self.assertEqual(len(s["parameters"].get("required", [])), 0)
78
-
79
- def test_descriptions_non_empty(self):
80
- for name, schema in [("v", schemas.XRAY_VALIDATE), ("c", schemas.XRAY_CODEX_CHECK), ("h", schemas.XRAY_HEALTH)]:
81
- self.assertTrue(len(schema["description"]) > 20, f"{name}")
82
-
83
-
84
- class TestRunXrayHelper(unittest.TestCase):
85
- """Test the CLI fallback helper (still exists for bridge-less environments)."""
86
-
87
- def test_successful_command(self):
88
- with patch("subprocess.run") as m:
89
- m.return_value = MagicMock(returncode=0, stdout="all good", stderr="")
90
- r = json.loads(tools_mod._run_xray(["health"]))
91
- self.assertEqual(r["status"], "ok")
92
- self.assertEqual(m.call_args[0][0], ["npx", "0xray", "health"])
93
-
94
- def test_command_failure(self):
95
- with patch("subprocess.run") as m:
96
- m.return_value = MagicMock(returncode=1, stdout="", stderr="broke")
97
- r = json.loads(tools_mod._run_xray(["validate"]))
98
- self.assertEqual(r["status"], "error")
99
-
100
- def test_file_not_found(self):
101
- with patch("subprocess.run", side_effect=FileNotFoundError):
102
- r = json.loads(tools_mod._run_xray(["health"]))
103
- self.assertIn("not found", r["error"])
104
-
105
- def test_timeout(self):
106
- with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("c", 30)):
107
- r = json.loads(tools_mod._run_xray(["health"], timeout=15))
108
- self.assertIn("15s", r["error"])
109
-
110
- def test_custom_timeout(self):
111
- with patch("subprocess.run") as m:
112
- m.return_value = MagicMock(returncode=0, stdout="", stderr="")
113
- tools_mod._run_xray(["health"], timeout=60)
114
- self.assertEqual(m.call_args[1]["timeout"], 60)
115
-
116
-
117
- class TestBridgeHelper(unittest.TestCase):
118
- """Test the bridge.mjs calling helper."""
119
-
120
- def test_successful_bridge_call(self):
121
- with patch("subprocess.run") as m:
122
- m.return_value = MagicMock(returncode=0, stdout='{"status":"ok"}', stderr="")
123
- r = tools_mod._bridge_call({"command": "health"})
124
- self.assertEqual(r["status"], "ok")
125
- # Should call node with bridge path
126
- self.assertIn("node", m.call_args[0][0])
127
- self.assertIn("bridge.mjs", m.call_args[0][0][1])
128
-
129
- def test_bridge_returns_error(self):
130
- with patch("subprocess.run") as m:
131
- m.return_value = MagicMock(returncode=1, stdout="", stderr="module not found")
132
- r = tools_mod._bridge_call({"command": "health"})
133
- self.assertIn("error", r)
134
-
135
- def test_bridge_node_not_found(self):
136
- with patch("subprocess.run", side_effect=FileNotFoundError):
137
- r = tools_mod._bridge_call({"command": "health"})
138
- self.assertEqual(r["error"], "node not found")
139
-
140
- def test_bridge_timeout(self):
141
- with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("c", 10)):
142
- r = tools_mod._bridge_call({"command": "health"}, timeout=10)
143
- self.assertIn("timed out", r["error"])
144
-
145
-
146
- class TestXrayHealth(unittest.TestCase):
147
- def test_health_via_bridge(self):
148
- """v2: health uses bridge first."""
149
- with patch.object(tools_mod, "_bridge_call", return_value={"status": "ok", "framework": "loaded", "version": "1.15.0", "components": {}}) as m:
150
- r = json.loads(tools_mod.xray_health({}))
151
- self.assertEqual(r["status"], "ok")
152
- self.assertEqual(r["via"], "bridge")
153
- m.assert_called_once_with({"command": "health"}, timeout=10)
154
-
155
- def test_health_fallback_to_cli(self):
156
- """v2: falls back to CLI when bridge fails."""
157
- with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}):
158
- with patch.object(tools_mod, "_run_xray", return_value='{"status":"ok","output":"healthy"}') as cli:
159
- r = json.loads(tools_mod.xray_health({}))
160
- cli.assert_called_once()
161
-
162
- def test_health_ignores_extra_args(self):
163
- with patch.object(tools_mod, "_bridge_call", return_value={"status": "ok", "framework": "loaded", "version": "1.0", "components": {}}):
164
- r = json.loads(tools_mod.xray_health({"x": 1}))
165
- self.assertEqual(r["status"], "ok")
166
-
167
-
168
- class TestXrayValidate(unittest.TestCase):
169
- def test_with_files_via_bridge(self):
170
- """v2: validate uses bridge first."""
171
- with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "fileResults": []}) as m:
172
- r = json.loads(tools_mod.xray_validate({"files": ["a.ts", "b.ts"], "operation": "commit"}))
173
- self.assertEqual(r["status"], "passed")
174
- self.assertEqual(r["files_checked"], 2)
175
- self.assertEqual(r["via"], "bridge")
176
- m.assert_called_once()
177
-
178
- def test_bridge_violations(self):
179
- with patch.object(tools_mod, "_bridge_call", return_value={"passed": False, "fileResults": [{"file": "a.ts", "passed": False, "violations": ["tests-required"]}]}) as m:
180
- r = json.loads(tools_mod.xray_validate({"files": ["a.ts"]}))
181
- self.assertEqual(r["status"], "violations")
182
- self.assertEqual(r["file_results"][0]["violations"], ["tests-required"])
183
-
184
- def test_bridge_error_fallback_to_cli(self):
185
- with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}):
186
- with patch.object(tools_mod, "_run_xray", return_value='{"status":"ok","output":"valid"}') as cli:
187
- r = json.loads(tools_mod.xray_validate({"files": ["a.ts"]}))
188
- self.assertEqual(r["via"], "cli")
189
- cli.assert_called_once()
190
-
191
- def test_no_files_error(self):
192
- r = json.loads(tools_mod.xray_validate({"files": []}))
193
- self.assertIn("No files", r["error"])
194
-
195
- def test_no_files_key_error(self):
196
- r = json.loads(tools_mod.xray_validate({}))
197
- self.assertIn("error", r)
198
-
199
- def test_default_operation(self):
200
- with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "fileResults": []}):
201
- r = json.loads(tools_mod.xray_validate({"files": ["a.ts"]}))
202
- self.assertEqual(r["operation"], "commit")
203
-
204
- def test_100_files(self):
205
- with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "fileResults": []}) as m:
206
- fs = [f"f{i}.ts" for i in range(100)]
207
- r = json.loads(tools_mod.xray_validate({"files": fs}))
208
- self.assertEqual(r["files_checked"], 100)
209
-
210
-
211
- class TestXrayCodexCheck(unittest.TestCase):
212
- def test_with_code_via_bridge(self):
213
- """v2: codex check uses bridge for real quality gate analysis."""
214
- with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}) as m:
215
- r = json.loads(tools_mod.xray_codex_check({"code": "const x = null;", "operation": "create"}))
216
- self.assertEqual(r["status"], "passed")
217
- self.assertEqual(r["via"], "bridge")
218
- m.assert_called_once()
219
-
220
- def test_with_code_bridge_violations(self):
221
- with patch.object(tools_mod, "_bridge_call", return_value={"passed": False, "violations": ["console.log found"], "checks": []}):
222
- r = json.loads(tools_mod.xray_codex_check({"code": "console.log(x)", "operation": "create"}))
223
- self.assertEqual(r["status"], "violations")
224
- self.assertEqual(r["violations"], ["console.log found"])
225
-
226
- def test_with_focus_areas(self):
227
- with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}) as m:
228
- tools_mod.xray_codex_check({"code": "eval()", "operation": "modify", "focus_areas": ["security"]})
229
- self.assertEqual(m.call_args[0][0]["focusAreas"], ["security"])
230
-
231
- def test_empty_string_code_treated_as_code(self):
232
- """BUG FIX: empty string '' should still be treated as code (is not None)."""
233
- with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}) as m:
234
- r = json.loads(tools_mod.xray_codex_check({"code": "", "operation": "create"}))
235
- self.assertEqual(r["status"], "passed")
236
- self.assertEqual(r["code_length"], 0)
237
-
238
- def test_no_code_bridge_health(self):
239
- """No code provided — bridge health check."""
240
- with patch.object(tools_mod, "_bridge_call", return_value={"framework": "loaded", "version": "1.15.0", "components": {}}) as m:
241
- r = json.loads(tools_mod.xray_codex_check({"operation": "refactor"}))
242
- self.assertEqual(r["status"], "ok")
243
- self.assertIn("Pass", r["note"])
244
-
245
- def test_no_code_bridge_error_fallback(self):
246
- with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}):
247
- with patch.object(tools_mod, "_run_xray", return_value='{"status":"ok","output":"healthy"}') as cli:
248
- r = json.loads(tools_mod.xray_codex_check({"operation": "create"}))
249
- cli.assert_called_once()
250
-
251
- def test_default_operation(self):
252
- with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}):
253
- r = json.loads(tools_mod.xray_codex_check({"code": "x"}))
254
- self.assertEqual(r["operation"], "create")
255
-
256
- def test_multiline_code(self):
257
- code = "function foo() {\n return null;\n}\n"
258
- with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}):
259
- r = json.loads(tools_mod.xray_codex_check({"code": code, "operation": "create"}))
260
- self.assertEqual(r["code_length"], len(code))
261
-
262
-
263
- class TestPreToolCallHook(unittest.TestCase):
264
- """v2: pre_tool_call now runs bridge for code tools and logs to disk."""
265
-
266
- def setUp(self):
267
- # Reset session stats
268
- pi._session_stats = dict.fromkeys(pi._session_stats, 0)
269
- pi._session_stats["started_at"] = None
270
- pi._session_stats["session_id"] = None
271
-
272
- @patch.object(pi, "_call_bridge")
273
- def test_xray_mcp_no_bridge(self, mock_bridge):
274
- """0xRay MCP tools skip bridge entirely."""
275
- pi._on_pre_tool_call("mcp_xray_lint_lint", {}, "t1")
276
- self.assertEqual(pi._session_stats["xray_mcp_calls"], 1)
277
- self.assertEqual(pi._session_stats["native_tool_calls"], 0)
278
- mock_bridge.assert_not_called()
279
-
280
- @patch.object(pi, "_call_bridge")
281
- def test_native_tool_no_bridge(self, mock_bridge):
282
- """Non-code native tools don't call bridge."""
283
- pi._on_pre_tool_call("read_file", {"path": "a.md"}, "t1")
284
- mock_bridge.assert_not_called()
285
-
286
- @patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True, "violations": []}, "processors": {"ran": False}})
287
- def test_code_tool_calls_bridge(self, mock_bridge):
288
- """Code-producing tools trigger bridge pre-process."""
289
- pi._on_pre_tool_call("write_file", {"path": "a.ts"}, "t1")
290
- mock_bridge.assert_called_once()
291
- call_cmd = mock_bridge.call_args[0][0]
292
- self.assertEqual(call_cmd["command"], "pre-process")
293
- self.assertEqual(call_cmd["tool"], "write_file")
294
-
295
- @patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True, "violations": []}, "processors": {"ran": False}})
296
- def test_code_tool_increments_stats(self, mock_bridge):
297
- for t in ["write_file", "patch", "execute_code"]:
298
- pi._session_stats["code_operations"] = 0
299
- pi._session_stats["quality_gate_runs"] = 0
300
- pi._on_pre_tool_call(t, {}, "t1")
301
- self.assertEqual(pi._session_stats["code_operations"], 1, f"{t}")
302
- self.assertEqual(pi._session_stats["quality_gate_runs"], 1, f"{t}")
303
-
304
- @patch.object(pi, "_call_bridge", return_value={"passed": False, "qualityGate": {"passed": False, "violations": ["tests-required: no test"]}, "processors": {"ran": False}})
305
- def test_quality_gate_block(self, mock_bridge):
306
- """Quality gate failures increment block counter."""
307
- pi._on_pre_tool_call("write_file", {"path": "a.ts"}, "t1")
308
- self.assertEqual(pi._session_stats["quality_gate_blocks"], 1)
309
-
310
- @patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True}, "processors": {"ran": True, "success": True, "processorCount": 2, "details": [{"name": "preValidate", "success": True}]}})
311
- def test_pre_processor_stats(self, mock_bridge):
312
- pi._on_pre_tool_call("write_file", {"path": "a.ts"}, "t1")
313
- self.assertEqual(pi._session_stats["pre_processor_runs"], 1)
314
-
315
- def test_nudge_terminal(self):
316
- """Terminal nudge only fires when command matches a known pattern."""
317
- # No command arg — no nudge
318
- with self.assertRaises(AssertionError):
319
- with self.assertLogs("xray-hermes", level="INFO"):
320
- pi._on_pre_tool_call("terminal", {}, "t1")
321
-
322
- # Generic command (git, ls) — no nudge
323
- with self.assertRaises(AssertionError):
324
- with self.assertLogs("xray-hermes", level="INFO"):
325
- pi._on_pre_tool_call("terminal", {"command": "git status"}, "t1")
326
-
327
- # Grep command — should nudge
328
- with self.assertLogs("xray-hermes", level="INFO") as cm:
329
- pi._on_pre_tool_call("terminal", {"command": "grep -r 'pattern' src/"}, "t1")
330
- self.assertTrue(any("grep" in m for m in cm.output))
331
-
332
- # npm audit — should nudge
333
- with self.assertLogs("xray-hermes", level="INFO") as cm:
334
- pi._on_pre_tool_call("terminal", {"command": "npm audit"}, "t1")
335
- self.assertTrue(any("audit" in m for m in cm.output))
336
-
337
- def test_nudge_search_files(self):
338
- with self.assertLogs("xray-hermes", level="INFO") as cm:
339
- pi._on_pre_tool_call("search_files", {}, "t1")
340
- self.assertTrue(any("Tip" in m for m in cm.output))
341
-
342
- def test_no_nudge_write_file(self):
343
- """write_file is a code tool — no nudge, gets bridge instead."""
344
- with patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True}, "processors": {"ran": False}}):
345
- with self.assertRaises(AssertionError):
346
- with self.assertLogs("xray-hermes", level="INFO"):
347
- pi._on_pre_tool_call("write_file", {}, "t1")
348
-
349
- def test_accumulates(self):
350
- with patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True}, "processors": {"ran": False}}):
351
- for _ in range(5):
352
- pi._on_pre_tool_call("terminal", {}, "t1")
353
- self.assertEqual(pi._session_stats["total_tool_calls"], 5)
354
-
355
- def test_is_xray_mcp(self):
356
- self.assertTrue(pi._is_xray_mcp("mcp_xray_lint_lint"))
357
- self.assertTrue(pi._is_xray_mcp("mcp_xray_enforcer_codex_enforcement"))
358
- self.assertFalse(pi._is_xray_mcp("terminal"))
359
- self.assertFalse(pi._is_xray_mcp("xray_validate"))
360
- self.assertFalse(pi._is_xray_mcp("mcp_other_tool"))
361
-
362
-
363
- class TestPostToolCallHook(unittest.TestCase):
364
- """v2: post_tool_call logs to disk and calls bridge for code tools."""
365
-
366
- def setUp(self):
367
- # Reset post_processor_runs since it accumulates across tests
368
- pi._session_stats["post_processor_runs"] = 0
369
-
370
- @patch.object(pi, "_call_bridge")
371
- def test_non_code_no_bridge(self, mock_bridge):
372
- """Non-code tools don't trigger bridge post-process."""
373
- pi._on_post_tool_call("terminal", {"command": "ls"}, None, "t1")
374
- mock_bridge.assert_not_called()
375
-
376
- @patch.object(pi, "_call_bridge", return_value={"processors": {"ran": True, "success": True, "processorCount": 2, "details": []}})
377
- def test_code_tool_calls_bridge(self, mock_bridge):
378
- """Code-producing tools trigger bridge post-process."""
379
- pi._on_post_tool_call("write_file", {"path": "a.ts"}, None, "t1")
380
- mock_bridge.assert_called_once()
381
- call_cmd = mock_bridge.call_args[0][0]
382
- self.assertEqual(call_cmd["command"], "post-process")
383
- self.assertEqual(call_cmd["tool"], "write_file")
384
-
385
- @patch.object(pi, "_call_bridge", return_value={"processors": {"ran": True, "success": True, "processorCount": 1, "details": []}})
386
- def test_post_processor_stats(self, mock_bridge):
387
- pi._on_post_tool_call("patch", {"path": "a.ts"}, None, "t1")
388
- self.assertEqual(pi._session_stats["post_processor_runs"], 1)
389
-
390
- @patch.object(pi, "_call_bridge", return_value={"processors": {"ran": True, "success": True, "processorCount": 2, "details": []}})
391
- def test_post_captures_result(self, mock_bridge):
392
- pi._on_post_tool_call("write_file", {"path": "a.ts"}, {"error": "disk full"}, "t1")
393
- call_cmd = mock_bridge.call_args[0][0]
394
- self.assertEqual(call_cmd["error"], "disk full")
395
-
396
- def test_args_not_dict_no_crash(self):
397
- pi._on_post_tool_call("write_file", "not a dict", None, "t1")
398
-
399
- def test_args_none_no_crash(self):
400
- pi._on_post_tool_call("write_file", None, None, "t1")
401
-
402
- def test_missing_path_key_no_file_tracking(self):
403
- """BUG FIX: missing path key should not crash."""
404
- pi._on_post_tool_call("write_file", {"content": "hello"}, None, "t1")
405
-
406
- def test_empty_path_no_file_tracking(self):
407
- """Empty string path should not crash."""
408
- pi._on_post_tool_call("write_file", {"path": ""}, None, "t1")
409
-
410
-
411
- class TestSlashCommand(unittest.TestCase):
412
- def setUp(self):
413
- pi._session_stats = {
414
- "started_at": "2026-03-27T15:00:00Z",
415
- "session_id": "test-session",
416
- "code_operations": 5,
417
- "total_tool_calls": 20,
418
- "xray_mcp_calls": 12,
419
- "native_tool_calls": 8,
420
- "quality_gate_runs": 10,
421
- "quality_gate_blocks": 2,
422
- "pre_processor_runs": 8,
423
- "post_processor_runs": 6,
424
- "bridge_calls": 15,
425
- "bridge_errors": 0,
426
- "subagent_dispatches": 3,
427
- "subagent_validations": 1,
428
- "subagent_blocks": 0,
429
- }
430
-
431
- def test_stats(self):
432
- o = pi._xray_command("stats")
433
- self.assertIn("20", o)
434
- self.assertIn("12", o)
435
- self.assertIn("8", o)
436
- self.assertIn("5", o)
437
- # v2 new stats
438
- self.assertIn("10", o) # quality_gate_runs
439
- self.assertIn("2", o) # quality_gate_blocks
440
- self.assertIn("15", o) # bridge_calls
441
-
442
- def test_help(self):
443
- o = pi._xray_command("help")
444
- self.assertIn("status", o)
445
- self.assertIn("help", o)
446
-
447
- def test_status_via_bridge(self):
448
- """v2: status uses bridge, not tools.xray_health."""
449
- with patch.object(pi, "_call_bridge", return_value={"framework": "loaded", "version": "1.15.0", "components": {"qualityGate": True, "processorManager": True}}) as m:
450
- o = pi._xray_command("status")
451
- self.assertIn("loaded", o)
452
- self.assertIn("1.15.0", o)
453
- m.assert_called_once_with({"command": "health"}, timeout=10)
454
-
455
- def test_status_bridge_error(self):
456
- with patch.object(pi, "_call_bridge", return_value={"error": "node not found"}):
457
- o = pi._xray_command("status")
458
- self.assertIn("node not found", o)
459
-
460
- def test_default_status(self):
461
- with patch.object(pi, "_call_bridge", return_value={"framework": "loaded", "version": "1.0", "components": {}}):
462
- o = pi._xray_command("")
463
- self.assertIn("loaded", o)
464
-
465
- def test_stats_null_started_at(self):
466
- pi._session_stats["started_at"] = None
467
- pi._session_stats["session_id"] = None
468
- o = pi._xray_command("stats")
469
- self.assertIn("N/A", o)
470
-
471
-
472
- class TestSessionStartHook(unittest.TestCase):
473
- def test_resets_stats(self):
474
- pi._session_stats["total_tool_calls"] = 99
475
- pi._session_stats["code_operations"] = 50
476
- pi._session_stats["bridge_calls"] = 20
477
- pi._session_stats["quality_gate_blocks"] = 5
478
- pi._on_session_start("s1", "cli")
479
- self.assertEqual(pi._session_stats["total_tool_calls"], 0)
480
- self.assertEqual(pi._session_stats["code_operations"], 0)
481
- self.assertEqual(pi._session_stats["bridge_calls"], 0)
482
- self.assertEqual(pi._session_stats["quality_gate_blocks"], 0)
483
- self.assertIsNotNone(pi._session_stats["started_at"])
484
- self.assertEqual(pi._session_stats["session_id"], "s1")
485
-
486
- def test_logs(self):
487
- with self.assertLogs("xray-hermes", level="INFO") as cm:
488
- pi._on_session_start("s1", "telegram")
489
- self.assertTrue(any("s1" in m for m in cm.output))
490
- self.assertTrue(any("telegram" in m for m in cm.output))
491
-
492
-
493
- class TestRegisterIntegration(unittest.TestCase):
494
- def test_wires_three_tools(self):
495
- ctx = MagicMock()
496
- pi.register(ctx)
497
- names = [c[1]["name"] for c in ctx.register_tool.call_args_list]
498
- self.assertEqual(set(names), {"xray_validate", "xray_codex_check", "xray_health", "xray_hooks"})
499
-
500
- def test_toolset_name(self):
501
- ctx = MagicMock()
502
- pi.register(ctx)
503
- for c in ctx.register_tool.call_args_list:
504
- self.assertEqual(c[1]["toolset"], "0xray-hermes")
505
-
506
- def test_schemas_wired(self):
507
- ctx = MagicMock()
508
- pi.register(ctx)
509
- sm = {c[1]["name"]: c[1]["schema"] for c in ctx.register_tool.call_args_list}
510
- self.assertIs(sm["xray_validate"], schemas.XRAY_VALIDATE)
511
- self.assertIs(sm["xray_codex_check"], schemas.XRAY_CODEX_CHECK)
512
- self.assertIs(sm["xray_health"], schemas.XRAY_HEALTH)
513
-
514
- def test_handlers_wired(self):
515
- ctx = MagicMock()
516
- pi.register(ctx)
517
- hm = {c[1]["name"]: c[1]["handler"] for c in ctx.register_tool.call_args_list}
518
- self.assertIs(hm["xray_validate"], tools_mod.xray_validate)
519
- self.assertIs(hm["xray_codex_check"], tools_mod.xray_codex_check)
520
- self.assertIs(hm["xray_health"], tools_mod.xray_health)
521
-
522
- def test_hooks_registered(self):
523
- ctx = MagicMock()
524
- pi.register(ctx)
525
- names = [c[0][0] for c in ctx.register_hook.call_args_list]
526
- self.assertIn("pre_tool_call", names)
527
- self.assertIn("post_tool_call", names)
528
-
529
- def test_hooks_are_callable(self):
530
- ctx = MagicMock()
531
- pi.register(ctx)
532
- for c in ctx.register_hook.call_args_list:
533
- self.assertTrue(callable(c[0][1]))
534
-
535
- def test_slash_command_attempted(self):
536
- ctx = MagicMock()
537
- pi.register(ctx)
538
- cmds = [c for c in ctx.method_calls if "register_command" in str(c)]
539
- self.assertTrue(len(cmds) >= 1)
540
-
541
- def test_survives_missing_session_hook(self):
542
- ctx = MagicMock()
543
- # pre_tool_call, post_tool_call succeed; on_session_start fails; 3 lifecycle hooks fail
544
- ctx.register_hook.side_effect = [None, None, AttributeError("nope"), AttributeError("nope"), AttributeError("nope"), AttributeError("nope")]
545
- pi.register(ctx) # should not raise
546
-
547
- def test_survives_missing_command_reg(self):
548
- ctx = MagicMock()
549
- ctx.register_command.side_effect = TypeError("nope")
550
- pi.register(ctx)
551
-
552
- def test_logs_on_load(self):
553
- ctx = MagicMock()
554
- with self.assertLogs("xray-hermes", level="INFO") as cm:
555
- pi.register(ctx)
556
- self.assertTrue(any("Plugin" in m and "loaded" in m for m in cm.output))
557
-
558
-
559
- class TestFileLogging(unittest.TestCase):
560
- """Test that tool events are written to log files."""
561
-
562
- def test_log_tool_event_creates_file(self):
563
- with tempfile.TemporaryDirectory() as td:
564
- log_dir = Path(td)
565
- # Temporarily override LOG_DIR
566
- original = pi.LOG_DIR
567
- pi.LOG_DIR = log_dir
568
-
569
- pi._log_tool_event("start", "terminal", {"command": "ls"})
570
- pi._log_tool_event("complete", "terminal", {"command": "ls"}, duration=100)
571
-
572
- activity_file = log_dir / "plugin-tool-events.log"
573
- self.assertTrue(activity_file.exists())
574
-
575
- content = activity_file.read_text()
576
- self.assertIn("tool-started", content)
577
- self.assertIn("tool-complete", content)
578
- self.assertIn("SUCCESS", content)
579
-
580
- pi.LOG_DIR = original
581
-
582
- def test_log_to_file_creates_file(self):
583
- with tempfile.TemporaryDirectory() as td:
584
- log_dir = Path(td)
585
- original = pi.LOG_DIR
586
- pi.LOG_DIR = log_dir
587
-
588
- pi._log_to_file("activity.log", "[test] hello world")
589
-
590
- activity_file = log_dir / "activity.log"
591
- self.assertTrue(activity_file.exists())
592
- content = activity_file.read_text()
593
- self.assertIn("[test] hello world", content)
594
-
595
- pi.LOG_DIR = original
596
-
597
- def test_log_to_file_survives_permission_error(self):
598
- """Should never crash the agent over logging."""
599
- original = pi.LOG_DIR
600
- pi.LOG_DIR = Path("/nonexistent/path/that/does/not/exist/and/cannot/be/created")
601
- pi._log_to_file("activity.log", "should not crash") # noqa: B023
602
- pi.LOG_DIR = original
603
-
604
-
605
- class TestLiveBridge(unittest.TestCase):
606
- """Live test: actually call bridge.mjs if it exists."""
607
-
608
- def test_bridge_health(self):
609
- bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
610
- if not bridge_path.exists():
611
- self.skipTest("bridge.mjs not built yet")
612
-
613
- r = tools_mod._bridge_call({"command": "health"}, timeout=10)
614
- self.assertNotIn("error", r)
615
- self.assertIn("status", r)
616
-
617
- def test_bridge_stats(self):
618
- bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
619
- if not bridge_path.exists():
620
- self.skipTest("bridge.mjs not built yet")
621
-
622
- r = tools_mod._bridge_call({"command": "stats"}, timeout=5)
623
- self.assertIn("frameworkReady", r)
624
-
625
- def test_bridge_quality_gate(self):
626
- bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
627
- if not bridge_path.exists():
628
- self.skipTest("bridge.mjs not built yet")
629
-
630
- # Clean code should pass
631
- r = tools_mod._bridge_call({
632
- "command": "codex-check",
633
- "code": "const x: number = 42;",
634
- }, timeout=10)
635
- self.assertNotIn("error", r)
636
- self.assertIn("passed", r)
637
-
638
- def test_bridge_quality_gate_violation(self):
639
- bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
640
- if not bridge_path.exists():
641
- self.skipTest("bridge.mjs not built yet")
642
-
643
- # Code with console.log should fail
644
- r = tools_mod._bridge_call({
645
- "command": "codex-check",
646
- "code": "console.log('hello');",
647
- }, timeout=10)
648
- self.assertNotIn("error", r)
649
- # console.log is a violation
650
- if r.get("passed") is False:
651
- self.assertTrue(
652
- any("console.log" in v.get("message", "") for v in r.get("violations", [])),
653
- f"Expected 'console.log' in violations, got: {r.get('violations', [])}",
654
- )
655
-
656
- def test_bridge_positional_health(self):
657
- """Positional arg mode: node bridge.mjs health --cwd /path (no stdin pipe)."""
658
- bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
659
- if not bridge_path.exists():
660
- self.skipTest("bridge.mjs not built yet")
661
-
662
- # Use stringray dev repo as project root (has package.json)
663
- xray_root = str(Path(PLUGIN_DIR).parent.parent.parent / "dev" / "stringray")
664
- cwd = xray_root if Path(xray_root).exists() else PLUGIN_DIR
665
-
666
- r = subprocess.run(
667
- ["node", str(bridge_path), "health", "--cwd", cwd],
668
- capture_output=True, text=True, timeout=10,
669
- )
670
- self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
671
- data = json.loads(r.stdout)
672
- self.assertEqual(data["status"], "ok")
673
- self.assertIn("framework", data)
674
-
675
- def test_bridge_positional_stats(self):
676
- """Positional stats: no stdin needed."""
677
- bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
678
- if not bridge_path.exists():
679
- self.skipTest("bridge.mjs not built yet")
680
-
681
- xray_root = str(Path(PLUGIN_DIR).parent.parent.parent / "dev" / "stringray")
682
- cwd = xray_root if Path(xray_root).exists() else PLUGIN_DIR
683
-
684
- r = subprocess.run(
685
- ["node", str(bridge_path), "stats", "--cwd", cwd],
686
- capture_output=True, text=True, timeout=10,
687
- )
688
- self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
689
- data = json.loads(r.stdout)
690
- self.assertIn("frameworkReady", data)
691
-
692
- def test_bridge_positional_with_json_payload(self):
693
- """Positional mode with --json payload for commands needing args."""
694
- bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
695
- if not bridge_path.exists():
696
- self.skipTest("bridge.mjs not built yet")
697
-
698
- xray_root = str(Path(PLUGIN_DIR).parent.parent.parent / "dev" / "stringray")
699
- cwd = xray_root if Path(xray_root).exists() else PLUGIN_DIR
700
-
701
- r = subprocess.run(
702
- ["node", str(bridge_path), "validate", "--cwd", cwd,
703
- "--json", json.dumps({"files": ["src/index.ts"], "operation": "commit"})],
704
- capture_output=True, text=True, timeout=15,
705
- )
706
- self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
707
- data = json.loads(r.stdout)
708
- self.assertIn("passed", data)
709
-
710
- def test_bridge_positional_invalid_json(self):
711
- """Positional mode with invalid --json returns error."""
712
- bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
713
- if not bridge_path.exists():
714
- self.skipTest("bridge.mjs not built yet")
715
-
716
- xray_root = str(Path(PLUGIN_DIR).parent.parent.parent / "dev" / "stringray")
717
- cwd = xray_root if Path(xray_root).exists() else PLUGIN_DIR
718
-
719
- r = subprocess.run(
720
- ["node", str(bridge_path), "validate", "--cwd", cwd,
721
- "--json", "not-json"],
722
- capture_output=True, text=True, timeout=10,
723
- )
724
- # Should fail with error about invalid payload
725
- data = json.loads(r.stdout)
726
- self.assertIn("error", data)
727
-
728
-
729
- class TestBridgeErrorPaths(unittest.TestCase):
730
- """Cover remaining bridge error branches in tools.py."""
731
-
732
- def test_bridge_json_decode_error(self):
733
- """_bridge_call with non-JSON stdout returns error."""
734
- with patch("subprocess.run") as m:
735
- m.return_value = MagicMock(returncode=0, stdout="not json", stderr="")
736
- r = tools_mod._bridge_call({"command": "health"})
737
- self.assertIn("error", r)
738
-
739
- def test_bridge_os_error(self):
740
- """_bridge_call with OSError during subprocess returns error."""
741
- with patch("subprocess.run", side_effect=OSError("broken pipe")):
742
- r = tools_mod._bridge_call({"command": "health"})
743
- self.assertIn("error", r)
744
-
745
- def test_bridge_generic_exception_in_run_xray(self):
746
- """_run_xray catches non-standard exceptions."""
747
- with patch("subprocess.run", side_effect=RuntimeError("unexpected")):
748
- r = json.loads(tools_mod._run_xray(["health"]))
749
- self.assertIn("unexpected", r["error"])
750
-
751
- def test_validate_cli_fallback_error(self):
752
- """xray_validate CLI fallback when CLI returns an error JSON."""
753
- with patch.object(tools_mod, "_bridge_call", return_value={"error": "bridge down"}):
754
- with patch.object(tools_mod, "_run_xray", return_value='{"error": "validation failed"}'):
755
- r = json.loads(tools_mod.xray_validate({"files": ["a.ts"]}))
756
- # CLI error path returns raw result without "via" key
757
- self.assertIn("error", r)
758
-
759
- def test_codex_check_static_fallback(self):
760
- """xray_codex_check with code but bridge down falls back to static analysis."""
761
- with patch.object(tools_mod, "_bridge_call", return_value={"error": "bridge down"}):
762
- r = json.loads(tools_mod.xray_codex_check({"code": "console.log(1)", "operation": "create"}))
763
- self.assertEqual(r["via"], "static")
764
- self.assertIn("basic analysis", r["note"])
765
-
766
- def test_codex_check_cli_health_error(self):
767
- """xray_codex_check no-code path: bridge error + CLI also errors."""
768
- with patch.object(tools_mod, "_bridge_call", return_value={"error": "no node"}):
769
- with patch.object(tools_mod, "_run_xray", return_value='{"error": "0xray not found"}'):
770
- r = json.loads(tools_mod.xray_codex_check({"operation": "create"}))
771
- self.assertIn("error", r)
772
-
773
-
774
- class TestGetProjectRoot(unittest.TestCase):
775
- """Test the _get_project_root helper in tools.py."""
776
-
777
- def test_returns_cwd_when_no_package_json(self):
778
- """With no package.json in any ancestor, falls back to cwd."""
779
- # _get_project_root delegates to __init__.py's PROJECT_ROOT.
780
- # We verify the function exists and is callable.
781
- self.assertTrue(callable(tools_mod._get_project_root))
782
- # Verify it returns a Path-like value
783
- result = tools_mod._get_project_root()
784
- self.assertIsNotNone(result)
785
-
786
-
787
- class TestPreToolCallBridgeErrors(unittest.TestCase):
788
- """Test pre_tool_call hook when bridge returns errors."""
789
-
790
- def setUp(self):
791
- pi._session_stats = dict.fromkeys(pi._session_stats, 0)
792
- pi._session_stats["started_at"] = None
793
- pi._session_stats["session_id"] = None
794
-
795
- @patch.object(pi, "_call_bridge", return_value={"error": "bridge crashed"})
796
- def test_code_tool_bridge_error_does_not_crash(self, mock_bridge):
797
- """Bridge error during pre-process should not crash the hook."""
798
- pi._on_pre_tool_call("write_file", {"path": "a.ts"}, "t1")
799
- self.assertEqual(pi._session_stats["code_operations"], 1)
800
- # Note: bridge_calls stat is inside the real _bridge_call,
801
- # so mocking it doesn't increment the counter. Verify hook doesn't crash.
802
- mock_bridge.assert_called_once()
803
-
804
- @patch.object(pi, "_call_bridge", return_value={"error": "timeout"})
805
- def test_multiple_code_tools_with_bridge_errors(self, mock_bridge):
806
- """Multiple bridge errors accumulate properly."""
807
- for i in range(3):
808
- pi._on_pre_tool_call("write_file", {"path": f"f{i}.ts"}, "t1")
809
- self.assertEqual(pi._session_stats["code_operations"], 3)
810
- self.assertEqual(pi._session_stats["total_tool_calls"], 3)
811
-
812
-
813
- class TestPostToolCallBridgeErrors(unittest.TestCase):
814
- """Test post_tool_call hook when bridge returns errors."""
815
-
816
- def setUp(self):
817
- pi._session_stats["post_processor_runs"] = 0
818
-
819
- @patch.object(pi, "_call_bridge", return_value={"error": "bridge down"})
820
- def test_code_tool_post_bridge_error(self, mock_bridge):
821
- """Bridge error during post-process should not crash."""
822
- pi._on_post_tool_call("write_file", {"path": "a.ts"}, None, "t1")
823
- mock_bridge.assert_called_once()
824
-
825
- @patch.object(pi, "_call_bridge", return_value={"processors": {"ran": False}})
826
- def test_code_tool_processors_not_ran(self, mock_bridge):
827
- """Processors not running is handled gracefully."""
828
- pi._on_post_tool_call("execute_code", {"command": "echo hi"}, {"duration": 42}, "t1")
829
- self.assertEqual(pi._session_stats["post_processor_runs"], 0)
830
-
831
- @patch.object(pi, "_call_bridge", return_value={"processors": {"ran": True, "success": False, "processorCount": 1, "details": [{"name": "testAutoCreation", "success": False, "error": "no test file"}]}})
832
- def test_post_processor_failure_logging(self, mock_bridge):
833
- """Failed post-processors are tracked but don't crash."""
834
- pi._on_post_tool_call("write_file", {"path": "a.ts"}, None, "t1")
835
- self.assertEqual(pi._session_stats["post_processor_runs"], 1)
836
-
837
-
838
- class TestPreToolCallEdgeCases(unittest.TestCase):
839
- """Edge cases for pre_tool_call."""
840
-
841
- def setUp(self):
842
- pi._session_stats = dict.fromkeys(pi._session_stats, 0)
843
- pi._session_stats["started_at"] = None
844
- pi._session_stats["session_id"] = None
845
-
846
- @patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True}, "processors": {"ran": False}})
847
- def test_all_code_tools_trigger_bridge(self, mock_bridge):
848
- """Every tool in _CODE_TOOLS calls the bridge."""
849
- code_tools = ["write_file", "patch", "execute_code", "write", "edit"]
850
- for tool in code_tools:
851
- pi._session_stats["code_operations"] = 0
852
- pi._on_pre_tool_call(tool, {}, "t1")
853
- self.assertEqual(pi._session_stats["code_operations"], 1, f"{tool} should be a code tool")
854
-
855
- @patch.object(pi, "_call_bridge")
856
- def test_unknown_tool_not_xray_mcp(self, mock_bridge):
857
- """Unknown tools should be treated as native tools."""
858
- pi._on_pre_tool_call("some_random_tool", {}, "t1")
859
- self.assertEqual(pi._session_stats["native_tool_calls"], 1)
860
- mock_bridge.assert_not_called()
861
-
862
- def test_xray_validate_tool_not_treated_as_mcp(self):
863
- """xray_validate is a native tool, not an MCP tool."""
864
- pi._on_pre_tool_call("xray_validate", {}, "t1")
865
- self.assertEqual(pi._session_stats["native_tool_calls"], 1)
866
- self.assertEqual(pi._session_stats["xray_mcp_calls"], 0)
867
-
868
- @patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True, "violations": []}, "processors": {"ran": True, "success": True, "processorCount": 3, "details": [{"name": "p1", "success": True}, {"name": "p2", "success": True}, {"name": "p3", "success": False, "error": "failed"}]}})
869
- def test_pre_processor_partial_failure(self, mock_bridge):
870
- """Pre-processors with partial failure still count as ran."""
871
- pi._on_pre_tool_call("write_file", {"path": "a.ts"}, "t1")
872
- self.assertEqual(pi._session_stats["pre_processor_runs"], 1)
873
-
874
-
875
- class TestSlashCommandEdgeCases(unittest.TestCase):
876
- """Edge cases for the slash command handler."""
877
-
878
- def test_unknown_command_defaults_to_status(self):
879
- """Unknown args default to status."""
880
- with patch.object(pi, "_call_bridge", return_value={"framework": "loaded", "version": "1.0", "components": {}}) as m:
881
- pi._xray_command("something-random")
882
- m.assert_called_once_with({"command": "health"}, timeout=10)
883
-
884
- def test_case_insensitive(self):
885
- """Command arg is lowercased."""
886
- o = pi._xray_command(" STATS ")
887
- self.assertIn("Session", o)
888
-
889
-
890
- class TestLogToFileTimestamps(unittest.TestCase):
891
- """Verify log file formatting."""
892
-
893
- def test_log_entry_has_iso_timestamp(self):
894
- """Every log entry should start with an ISO timestamp."""
895
- import re
896
- with tempfile.TemporaryDirectory() as td:
897
- log_dir = Path(td)
898
- original = pi.LOG_DIR
899
- pi.LOG_DIR = log_dir
900
-
901
- pi._log_to_file("test.log", "[test] message")
902
- content = (log_dir / "test.log").read_text()
903
- # ISO timestamp: 2026-03-27T17:00:00Z or similar
904
- self.assertTrue(re.match(r"\d{4}-\d{2}-\d{2}T", content.split(" ")[0]))
905
-
906
- pi.LOG_DIR = original
907
-
908
-
909
- class TestPostToolCallDuration(unittest.TestCase):
910
- """Test that duration is correctly extracted from results."""
911
-
912
- def setUp(self):
913
- pi._session_stats["post_processor_runs"] = 0
914
-
915
- @patch.object(pi, "_call_bridge", return_value={"processors": {"ran": False}})
916
- def test_duration_extracted_from_result_dict(self, mock_bridge):
917
- """Duration from result dict is logged correctly."""
918
- # We verify the post hook doesn't crash with duration in result
919
- pi._on_post_tool_call("write_file", {"path": "a.ts"}, {"duration": 1234, "success": True}, "t1")
920
- # No crash = pass
921
-
922
- @patch.object(pi, "_call_bridge", return_value={"processors": {"ran": False}})
923
- def test_non_dict_result_no_crash(self, mock_bridge):
924
- """Non-dict result doesn't crash the hook."""
925
- pi._on_post_tool_call("write_file", {"path": "a.ts"}, "string result", "t1")
926
-
927
- @patch.object(pi, "_call_bridge", return_value={"processors": {"ran": False}})
928
- def test_none_result_no_crash(self, mock_bridge):
929
- """None result doesn't crash the hook."""
930
- pi._on_post_tool_call("write_file", {"path": "a.ts"}, None, "t1")
931
-
932
-
933
- class TestBridgeHelperTimeoutDefault(unittest.TestCase):
934
- """Verify bridge timeout defaults."""
935
-
936
- def test_default_timeout(self):
937
- """_bridge_call defaults to 30s timeout."""
938
- with patch("subprocess.run") as m:
939
- m.return_value = MagicMock(returncode=0, stdout='{"ok":true}', stderr="")
940
- tools_mod._bridge_call({"command": "health"})
941
- self.assertEqual(m.call_args[1]["timeout"], 30)
942
-
943
- def test_custom_timeout(self):
944
- """_bridge_call respects custom timeout."""
945
- with patch("subprocess.run") as m:
946
- m.return_value = MagicMock(returncode=0, stdout='{"ok":true}', stderr="")
947
- tools_mod._bridge_call({"command": "health"}, timeout=5)
948
- self.assertEqual(m.call_args[1]["timeout"], 5)
949
-
950
-
951
- class TestXrayHooksTool(unittest.TestCase):
952
- """Tests for the xray_hooks tool."""
953
-
954
- def test_list_via_bridge(self):
955
- """list action uses bridge when available."""
956
- with patch.object(tools_mod, "_bridge_call", return_value={
957
- "status": "ok", "action": "list",
958
- "hooks": {"managed": ["pre-commit"], "missing": [], "external": [], "stale": []},
959
- }) as m:
960
- r = json.loads(tools_mod.xray_hooks({"action": "list"}))
961
- self.assertEqual(r["status"], "ok")
962
- self.assertEqual(r["via"], "bridge")
963
- m.assert_called_once()
964
- call_cmd = m.call_args[0][0]
965
- self.assertEqual(call_cmd["command"], "hooks")
966
- self.assertEqual(call_cmd["action"], "list")
967
-
968
- def test_install_via_bridge(self):
969
- """install action uses bridge."""
970
- with patch.object(tools_mod, "_bridge_call", return_value={
971
- "status": "ok", "action": "install", "installed": ["pre-commit", "post-commit"],
972
- "skipped": [], "errors": [],
973
- }) as m:
974
- r = json.loads(tools_mod.xray_hooks({"action": "install"}))
975
- self.assertEqual(r["via"], "bridge")
976
- self.assertEqual(len(r["result"]["installed"]), 2)
977
-
978
- def test_uninstall_via_bridge(self):
979
- """uninstall action uses bridge."""
980
- with patch.object(tools_mod, "_bridge_call", return_value={
981
- "status": "ok", "action": "uninstall", "removed": ["pre-commit"], "restored": [],
982
- }) as m:
983
- r = json.loads(tools_mod.xray_hooks({"action": "uninstall"}))
984
- self.assertEqual(r["via"], "bridge")
985
-
986
- def test_bridge_error_fallback(self):
987
- """Falls back to file-based when bridge errors."""
988
- with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}):
989
- # Without a real git repo, should return error
990
- r = json.loads(tools_mod.xray_hooks({"action": "list"}))
991
- self.assertIn("via", r)
992
-
993
- def test_specific_hooks(self):
994
- """Can request specific hooks."""
995
- with patch.object(tools_mod, "_bridge_call", return_value={
996
- "status": "ok", "action": "list",
997
- "hooks": {"managed": [], "missing": ["pre-commit"], "external": [], "stale": []},
998
- }) as m:
999
- tools_mod.xray_hooks({"action": "list", "hooks": ["pre-commit"]})
1000
- call_cmd = m.call_args[0][0]
1001
- self.assertEqual(call_cmd["hooks"], ["pre-commit"])
1002
-
1003
- def test_status_defaults_to_list(self):
1004
- """status action works like list."""
1005
- with patch.object(tools_mod, "_bridge_call", return_value={
1006
- "status": "ok", "action": "status",
1007
- "hooks": {"managed": [], "missing": [], "external": [], "stale": []},
1008
- }) as m:
1009
- r = json.loads(tools_mod.xray_hooks({"action": "status"}))
1010
- self.assertEqual(r["status"], "ok")
1011
- m.assert_called_once()
1012
-
1013
- def test_default_action_is_list(self):
1014
- """Missing action defaults to list."""
1015
- with patch.object(tools_mod, "_bridge_call", return_value={
1016
- "status": "ok", "action": "list",
1017
- "hooks": {"managed": [], "missing": [], "external": [], "stale": []},
1018
- }) as m:
1019
- tools_mod.xray_hooks({})
1020
- call_cmd = m.call_args[0][0]
1021
- self.assertEqual(call_cmd["action"], "list")
1022
-
1023
-
1024
- class TestXrayHooksSchema(unittest.TestCase):
1025
- """Tests for the XRAY_HOOKS schema."""
1026
-
1027
- def test_schema_has_required_fields(self):
1028
- s = schemas.XRAY_HOOKS
1029
- self.assertEqual(s["name"], "xray_hooks")
1030
- self.assertIn("action", s["parameters"]["properties"])
1031
- self.assertIn("hooks", s["parameters"]["properties"])
1032
- self.assertIn("action", s["parameters"]["required"])
1033
-
1034
- def test_action_enum(self):
1035
- s = schemas.XRAY_HOOKS
1036
- action = s["parameters"]["properties"]["action"]
1037
- self.assertIn("install", action["enum"])
1038
- self.assertIn("uninstall", action["enum"])
1039
- self.assertIn("list", action["enum"])
1040
- self.assertIn("status", action["enum"])
1041
-
1042
- def test_hooks_enum(self):
1043
- s = schemas.XRAY_HOOKS
1044
- hooks = s["parameters"]["properties"]["hooks"]
1045
- self.assertIn("pre-commit", hooks["items"]["enum"])
1046
- self.assertIn("post-commit", hooks["items"]["enum"])
1047
- self.assertIn("pre-push", hooks["items"]["enum"])
1048
- self.assertIn("post-push", hooks["items"]["enum"])
1049
-
1050
- def test_description_mentions_hooks(self):
1051
- s = schemas.XRAY_HOOKS
1052
- self.assertIn("git hooks", s["description"])
1053
-
1054
-
1055
- class TestRegisterIntegrationV2_1(unittest.TestCase):
1056
- """Test that register() wires all 4 tools and 2 hooks in v2.2."""
1057
-
1058
- def test_wires_four_tools(self):
1059
- ctx = MagicMock()
1060
- pi.register(ctx)
1061
- names = [c[1]["name"] for c in ctx.register_tool.call_args_list]
1062
- self.assertEqual(set(names), {
1063
- "xray_validate", "xray_codex_check",
1064
- "xray_health", "xray_hooks",
1065
- })
1066
-
1067
- def test_xray_hooks_schema_wired(self):
1068
- ctx = MagicMock()
1069
- pi.register(ctx)
1070
- sm = {c[1]["name"]: c[1]["schema"] for c in ctx.register_tool.call_args_list}
1071
- self.assertIs(sm["xray_hooks"], schemas.XRAY_HOOKS)
1072
-
1073
- def test_xray_hooks_handler_wired(self):
1074
- ctx = MagicMock()
1075
- pi.register(ctx)
1076
- hm = {c[1]["name"]: c[1]["handler"] for c in ctx.register_tool.call_args_list}
1077
- self.assertIs(hm["xray_hooks"], tools_mod.xray_hooks)
1078
-
1079
- def test_registers_two_hooks(self):
1080
- ctx = MagicMock()
1081
- pi.register(ctx)
1082
- hook_names = [c[0][0] for c in ctx.register_hook.call_args_list]
1083
- self.assertIn("pre_tool_call", hook_names)
1084
- self.assertIn("post_tool_call", hook_names)
1085
-
1086
- def test_survives_missing_session_hook(self):
1087
- """Session hook should fail gracefully if not supported."""
1088
- ctx = MagicMock()
1089
- def side_effect(*args):
1090
- raise AttributeError("not available")
1091
- ctx.register_hook.side_effect = [None, None, side_effect]
1092
- pi.register(ctx) # should not raise
1093
-
1094
- def test_v2_2_log_message(self):
1095
- ctx = MagicMock()
1096
- with self.assertLogs("xray-hermes", level="INFO") as cm:
1097
- pi.register(ctx)
1098
- self.assertTrue(any("v2.2" in m for m in cm.output))
1099
- self.assertTrue(any("4 tools" in m for m in cm.output))
1100
-
1101
-
1102
- if __name__ == "__main__":
1103
- unittest.main(verbosity=2)