nc1709 1.15.4__py3-none-any.whl → 1.18.8__py3-none-any.whl
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.
- nc1709/__init__.py +1 -1
- nc1709/agent/core.py +172 -19
- nc1709/agent/permissions.py +2 -2
- nc1709/agent/tools/bash_tool.py +295 -8
- nc1709/cli.py +435 -19
- nc1709/cli_ui.py +137 -52
- nc1709/conversation_logger.py +416 -0
- nc1709/llm_adapter.py +62 -4
- nc1709/plugins/agents/database_agent.py +695 -0
- nc1709/plugins/agents/django_agent.py +11 -4
- nc1709/plugins/agents/docker_agent.py +11 -4
- nc1709/plugins/agents/fastapi_agent.py +11 -4
- nc1709/plugins/agents/git_agent.py +11 -4
- nc1709/plugins/agents/nextjs_agent.py +11 -4
- nc1709/plugins/agents/ollama_agent.py +574 -0
- nc1709/plugins/agents/test_agent.py +702 -0
- nc1709/prompts/unified_prompt.py +156 -14
- nc1709/requirements_tracker.py +526 -0
- nc1709/thinking_messages.py +337 -0
- nc1709/version_check.py +6 -2
- nc1709/web/server.py +63 -3
- nc1709/web/templates/index.html +819 -140
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/METADATA +10 -7
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/RECORD +28 -22
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/WHEEL +0 -0
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/entry_points.txt +0 -0
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/licenses/LICENSE +0 -0
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Testing Agent for NC1709
|
|
3
|
+
Handles test execution, discovery, and coverage reporting
|
|
4
|
+
"""
|
|
5
|
+
import subprocess
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Any, Optional, List
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from ..base import (
|
|
15
|
+
Plugin, PluginMetadata, PluginCapability,
|
|
16
|
+
ActionResult
|
|
17
|
+
)
|
|
18
|
+
except ImportError:
|
|
19
|
+
# When loaded dynamically via importlib
|
|
20
|
+
from nc1709.plugins.base import (
|
|
21
|
+
Plugin, PluginMetadata, PluginCapability,
|
|
22
|
+
ActionResult
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TestResult:
|
|
28
|
+
"""Represents a test result"""
|
|
29
|
+
name: str
|
|
30
|
+
status: str # passed, failed, skipped, error
|
|
31
|
+
duration: float = 0.0
|
|
32
|
+
message: str = ""
|
|
33
|
+
file: str = ""
|
|
34
|
+
line: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class CoverageReport:
|
|
39
|
+
"""Represents test coverage information"""
|
|
40
|
+
total_lines: int = 0
|
|
41
|
+
covered_lines: int = 0
|
|
42
|
+
missing_lines: int = 0
|
|
43
|
+
coverage_percent: float = 0.0
|
|
44
|
+
files: Dict[str, float] = None
|
|
45
|
+
|
|
46
|
+
def __post_init__(self):
|
|
47
|
+
if self.files is None:
|
|
48
|
+
self.files = {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestAgent(Plugin):
|
|
52
|
+
"""
|
|
53
|
+
Testing framework agent.
|
|
54
|
+
|
|
55
|
+
Provides testing operations across frameworks:
|
|
56
|
+
- Test discovery (find tests)
|
|
57
|
+
- Test execution (run tests)
|
|
58
|
+
- Coverage reporting
|
|
59
|
+
- Test result analysis
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
METADATA = PluginMetadata(
|
|
63
|
+
name="test",
|
|
64
|
+
version="1.0.0",
|
|
65
|
+
description="Test automation and coverage",
|
|
66
|
+
author="NC1709 Team",
|
|
67
|
+
capabilities=[
|
|
68
|
+
PluginCapability.COMMAND_EXECUTION
|
|
69
|
+
],
|
|
70
|
+
keywords=[
|
|
71
|
+
"test", "pytest", "jest", "mocha", "vitest", "unittest",
|
|
72
|
+
"coverage", "tdd", "testing", "spec", "rspec", "go test",
|
|
73
|
+
"cargo test", "junit", "nose", "tap"
|
|
74
|
+
],
|
|
75
|
+
config_schema={
|
|
76
|
+
"default_framework": {"type": "string", "default": "auto"},
|
|
77
|
+
"coverage_threshold": {"type": "number", "default": 80},
|
|
78
|
+
"verbose": {"type": "boolean", "default": True}
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Test framework detection patterns
|
|
83
|
+
FRAMEWORK_PATTERNS = {
|
|
84
|
+
"pytest": ["pytest.ini", "pyproject.toml", "setup.cfg", "conftest.py"],
|
|
85
|
+
"jest": ["jest.config.js", "jest.config.ts", "jest.config.json"],
|
|
86
|
+
"vitest": ["vitest.config.js", "vitest.config.ts", "vite.config.js"],
|
|
87
|
+
"mocha": [".mocharc.js", ".mocharc.json", ".mocharc.yaml"],
|
|
88
|
+
"go": ["go.mod", "*_test.go"],
|
|
89
|
+
"cargo": ["Cargo.toml"],
|
|
90
|
+
"rspec": [".rspec", "spec/spec_helper.rb"],
|
|
91
|
+
"phpunit": ["phpunit.xml", "phpunit.xml.dist"],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Commands for each framework
|
|
95
|
+
FRAMEWORK_COMMANDS = {
|
|
96
|
+
"pytest": {
|
|
97
|
+
"run": "pytest",
|
|
98
|
+
"discover": "pytest --collect-only -q",
|
|
99
|
+
"coverage": "pytest --cov --cov-report=json",
|
|
100
|
+
"verbose": "-v",
|
|
101
|
+
"file_pattern": "test_*.py",
|
|
102
|
+
},
|
|
103
|
+
"jest": {
|
|
104
|
+
"run": "npx jest",
|
|
105
|
+
"discover": "npx jest --listTests",
|
|
106
|
+
"coverage": "npx jest --coverage --coverageReporters=json",
|
|
107
|
+
"verbose": "--verbose",
|
|
108
|
+
"file_pattern": "*.test.{js,ts,jsx,tsx}",
|
|
109
|
+
},
|
|
110
|
+
"vitest": {
|
|
111
|
+
"run": "npx vitest run",
|
|
112
|
+
"discover": "npx vitest --list",
|
|
113
|
+
"coverage": "npx vitest run --coverage",
|
|
114
|
+
"verbose": "--reporter=verbose",
|
|
115
|
+
"file_pattern": "*.test.{js,ts,jsx,tsx}",
|
|
116
|
+
},
|
|
117
|
+
"mocha": {
|
|
118
|
+
"run": "npx mocha",
|
|
119
|
+
"discover": "npx mocha --dry-run",
|
|
120
|
+
"coverage": "npx nyc mocha",
|
|
121
|
+
"verbose": "--reporter spec",
|
|
122
|
+
"file_pattern": "*.test.js",
|
|
123
|
+
},
|
|
124
|
+
"go": {
|
|
125
|
+
"run": "go test ./...",
|
|
126
|
+
"discover": "go test -list . ./...",
|
|
127
|
+
"coverage": "go test -cover ./...",
|
|
128
|
+
"verbose": "-v",
|
|
129
|
+
"file_pattern": "*_test.go",
|
|
130
|
+
},
|
|
131
|
+
"cargo": {
|
|
132
|
+
"run": "cargo test",
|
|
133
|
+
"discover": "cargo test -- --list",
|
|
134
|
+
"coverage": "cargo tarpaulin --out Json",
|
|
135
|
+
"verbose": "-- --nocapture",
|
|
136
|
+
"file_pattern": "*.rs",
|
|
137
|
+
},
|
|
138
|
+
"rspec": {
|
|
139
|
+
"run": "bundle exec rspec",
|
|
140
|
+
"discover": "bundle exec rspec --dry-run",
|
|
141
|
+
"coverage": "bundle exec rspec", # Uses simplecov
|
|
142
|
+
"verbose": "--format documentation",
|
|
143
|
+
"file_pattern": "*_spec.rb",
|
|
144
|
+
},
|
|
145
|
+
"phpunit": {
|
|
146
|
+
"run": "vendor/bin/phpunit",
|
|
147
|
+
"discover": "vendor/bin/phpunit --list-tests",
|
|
148
|
+
"coverage": "vendor/bin/phpunit --coverage-text",
|
|
149
|
+
"verbose": "--verbose",
|
|
150
|
+
"file_pattern": "*Test.php",
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def metadata(self) -> PluginMetadata:
|
|
156
|
+
return self.METADATA
|
|
157
|
+
|
|
158
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
159
|
+
super().__init__(config)
|
|
160
|
+
self._detected_framework = None
|
|
161
|
+
|
|
162
|
+
def initialize(self) -> bool:
|
|
163
|
+
"""Initialize the testing agent"""
|
|
164
|
+
# Detect available test framework
|
|
165
|
+
self._detected_framework = self._detect_framework()
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
def cleanup(self) -> None:
|
|
169
|
+
"""Cleanup resources"""
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
def _detect_framework(self) -> Optional[str]:
|
|
173
|
+
"""Detect the testing framework used in the project"""
|
|
174
|
+
cwd = Path.cwd()
|
|
175
|
+
|
|
176
|
+
for framework, patterns in self.FRAMEWORK_PATTERNS.items():
|
|
177
|
+
for pattern in patterns:
|
|
178
|
+
if "*" in pattern:
|
|
179
|
+
# Glob pattern
|
|
180
|
+
if list(cwd.glob(pattern)) or list(cwd.glob(f"**/{pattern}")):
|
|
181
|
+
return framework
|
|
182
|
+
else:
|
|
183
|
+
# Exact file
|
|
184
|
+
if (cwd / pattern).exists():
|
|
185
|
+
return framework
|
|
186
|
+
|
|
187
|
+
# Check pyproject.toml for pytest
|
|
188
|
+
pyproject = cwd / "pyproject.toml"
|
|
189
|
+
if pyproject.exists():
|
|
190
|
+
content = pyproject.read_text()
|
|
191
|
+
if "[tool.pytest" in content:
|
|
192
|
+
return "pytest"
|
|
193
|
+
|
|
194
|
+
# Check package.json for test scripts
|
|
195
|
+
pkg_json = cwd / "package.json"
|
|
196
|
+
if pkg_json.exists():
|
|
197
|
+
try:
|
|
198
|
+
data = json.loads(pkg_json.read_text())
|
|
199
|
+
scripts = data.get("scripts", {})
|
|
200
|
+
test_script = scripts.get("test", "")
|
|
201
|
+
if "jest" in test_script:
|
|
202
|
+
return "jest"
|
|
203
|
+
elif "vitest" in test_script:
|
|
204
|
+
return "vitest"
|
|
205
|
+
elif "mocha" in test_script:
|
|
206
|
+
return "mocha"
|
|
207
|
+
except json.JSONDecodeError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
def _register_actions(self) -> None:
|
|
213
|
+
"""Register testing actions"""
|
|
214
|
+
self.register_action(
|
|
215
|
+
"run",
|
|
216
|
+
self.run_tests,
|
|
217
|
+
"Run tests",
|
|
218
|
+
parameters={
|
|
219
|
+
"path": {"type": "string", "optional": True},
|
|
220
|
+
"filter": {"type": "string", "optional": True},
|
|
221
|
+
"verbose": {"type": "boolean", "default": True},
|
|
222
|
+
"framework": {"type": "string", "optional": True},
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
self.register_action(
|
|
227
|
+
"discover",
|
|
228
|
+
self.discover_tests,
|
|
229
|
+
"Discover available tests",
|
|
230
|
+
parameters={
|
|
231
|
+
"path": {"type": "string", "optional": True},
|
|
232
|
+
"framework": {"type": "string", "optional": True},
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
self.register_action(
|
|
237
|
+
"coverage",
|
|
238
|
+
self.run_coverage,
|
|
239
|
+
"Run tests with coverage",
|
|
240
|
+
parameters={
|
|
241
|
+
"path": {"type": "string", "optional": True},
|
|
242
|
+
"threshold": {"type": "number", "optional": True},
|
|
243
|
+
"framework": {"type": "string", "optional": True},
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
self.register_action(
|
|
248
|
+
"watch",
|
|
249
|
+
self.watch_tests,
|
|
250
|
+
"Run tests in watch mode",
|
|
251
|
+
parameters={
|
|
252
|
+
"path": {"type": "string", "optional": True},
|
|
253
|
+
"framework": {"type": "string", "optional": True},
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
self.register_action(
|
|
258
|
+
"failed",
|
|
259
|
+
self.run_failed,
|
|
260
|
+
"Re-run only failed tests",
|
|
261
|
+
parameters={
|
|
262
|
+
"framework": {"type": "string", "optional": True},
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
self.register_action(
|
|
267
|
+
"detect",
|
|
268
|
+
self.detect_framework_action,
|
|
269
|
+
"Detect testing framework"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _run_command(self, cmd: str, timeout: int = 1200) -> subprocess.CompletedProcess:
|
|
273
|
+
"""Run a command and return the result"""
|
|
274
|
+
return subprocess.run(
|
|
275
|
+
cmd,
|
|
276
|
+
shell=True,
|
|
277
|
+
capture_output=True,
|
|
278
|
+
text=True,
|
|
279
|
+
timeout=timeout,
|
|
280
|
+
cwd=str(Path.cwd())
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def _get_framework(self, specified: Optional[str] = None) -> Optional[str]:
|
|
284
|
+
"""Get the framework to use"""
|
|
285
|
+
if specified:
|
|
286
|
+
return specified
|
|
287
|
+
if self._detected_framework:
|
|
288
|
+
return self._detected_framework
|
|
289
|
+
return self._detect_framework()
|
|
290
|
+
|
|
291
|
+
def run_tests(
|
|
292
|
+
self,
|
|
293
|
+
path: Optional[str] = None,
|
|
294
|
+
filter: Optional[str] = None,
|
|
295
|
+
verbose: bool = True,
|
|
296
|
+
framework: Optional[str] = None
|
|
297
|
+
) -> ActionResult:
|
|
298
|
+
"""Run tests
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
path: Path to test file or directory
|
|
302
|
+
filter: Test name filter/pattern
|
|
303
|
+
verbose: Show verbose output
|
|
304
|
+
framework: Force specific framework
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
ActionResult with test results
|
|
308
|
+
"""
|
|
309
|
+
fw = self._get_framework(framework)
|
|
310
|
+
if not fw:
|
|
311
|
+
return ActionResult.fail(
|
|
312
|
+
"Could not detect testing framework. "
|
|
313
|
+
"Please specify one or ensure test config files exist."
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if fw not in self.FRAMEWORK_COMMANDS:
|
|
317
|
+
return ActionResult.fail(f"Unsupported framework: {fw}")
|
|
318
|
+
|
|
319
|
+
config = self.FRAMEWORK_COMMANDS[fw]
|
|
320
|
+
cmd = config["run"]
|
|
321
|
+
|
|
322
|
+
if verbose:
|
|
323
|
+
cmd += f" {config['verbose']}"
|
|
324
|
+
|
|
325
|
+
if path:
|
|
326
|
+
cmd += f" {path}"
|
|
327
|
+
|
|
328
|
+
if filter:
|
|
329
|
+
if fw == "pytest":
|
|
330
|
+
cmd += f" -k '{filter}'"
|
|
331
|
+
elif fw in ["jest", "vitest"]:
|
|
332
|
+
cmd += f" -t '{filter}'"
|
|
333
|
+
elif fw == "go":
|
|
334
|
+
cmd += f" -run '{filter}'"
|
|
335
|
+
elif fw == "cargo":
|
|
336
|
+
cmd += f" {filter}"
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
result = self._run_command(cmd)
|
|
340
|
+
|
|
341
|
+
# Parse results
|
|
342
|
+
output = result.stdout + result.stderr
|
|
343
|
+
passed, failed, skipped = self._parse_test_output(output, fw)
|
|
344
|
+
|
|
345
|
+
status = "passed" if result.returncode == 0 else "failed"
|
|
346
|
+
message = f"Tests {status}: {passed} passed"
|
|
347
|
+
if failed > 0:
|
|
348
|
+
message += f", {failed} failed"
|
|
349
|
+
if skipped > 0:
|
|
350
|
+
message += f", {skipped} skipped"
|
|
351
|
+
|
|
352
|
+
return ActionResult(
|
|
353
|
+
success=result.returncode == 0,
|
|
354
|
+
message=message,
|
|
355
|
+
data={
|
|
356
|
+
"framework": fw,
|
|
357
|
+
"passed": passed,
|
|
358
|
+
"failed": failed,
|
|
359
|
+
"skipped": skipped,
|
|
360
|
+
"output": output,
|
|
361
|
+
"return_code": result.returncode,
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
except subprocess.TimeoutExpired:
|
|
366
|
+
return ActionResult.fail("Tests timed out. Consider running specific tests.")
|
|
367
|
+
except Exception as e:
|
|
368
|
+
return ActionResult.fail(f"Error running tests: {e}")
|
|
369
|
+
|
|
370
|
+
def discover_tests(
|
|
371
|
+
self,
|
|
372
|
+
path: Optional[str] = None,
|
|
373
|
+
framework: Optional[str] = None
|
|
374
|
+
) -> ActionResult:
|
|
375
|
+
"""Discover available tests
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
path: Path to search
|
|
379
|
+
framework: Force specific framework
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
ActionResult with discovered tests
|
|
383
|
+
"""
|
|
384
|
+
fw = self._get_framework(framework)
|
|
385
|
+
if not fw:
|
|
386
|
+
return ActionResult.fail("Could not detect testing framework.")
|
|
387
|
+
|
|
388
|
+
if fw not in self.FRAMEWORK_COMMANDS:
|
|
389
|
+
return ActionResult.fail(f"Unsupported framework: {fw}")
|
|
390
|
+
|
|
391
|
+
config = self.FRAMEWORK_COMMANDS[fw]
|
|
392
|
+
cmd = config["discover"]
|
|
393
|
+
|
|
394
|
+
if path:
|
|
395
|
+
cmd += f" {path}"
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
result = self._run_command(cmd, timeout=120)
|
|
399
|
+
output = result.stdout
|
|
400
|
+
|
|
401
|
+
# Count tests
|
|
402
|
+
test_count = self._count_tests(output, fw)
|
|
403
|
+
|
|
404
|
+
return ActionResult.ok(
|
|
405
|
+
message=f"Found {test_count} tests using {fw}",
|
|
406
|
+
data={
|
|
407
|
+
"framework": fw,
|
|
408
|
+
"test_count": test_count,
|
|
409
|
+
"output": output,
|
|
410
|
+
}
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
except Exception as e:
|
|
414
|
+
return ActionResult.fail(f"Error discovering tests: {e}")
|
|
415
|
+
|
|
416
|
+
def run_coverage(
|
|
417
|
+
self,
|
|
418
|
+
path: Optional[str] = None,
|
|
419
|
+
threshold: Optional[float] = None,
|
|
420
|
+
framework: Optional[str] = None
|
|
421
|
+
) -> ActionResult:
|
|
422
|
+
"""Run tests with coverage
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
path: Path to test
|
|
426
|
+
threshold: Minimum coverage threshold
|
|
427
|
+
framework: Force specific framework
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
ActionResult with coverage report
|
|
431
|
+
"""
|
|
432
|
+
fw = self._get_framework(framework)
|
|
433
|
+
if not fw:
|
|
434
|
+
return ActionResult.fail("Could not detect testing framework.")
|
|
435
|
+
|
|
436
|
+
if fw not in self.FRAMEWORK_COMMANDS:
|
|
437
|
+
return ActionResult.fail(f"Unsupported framework: {fw}")
|
|
438
|
+
|
|
439
|
+
config = self.FRAMEWORK_COMMANDS[fw]
|
|
440
|
+
cmd = config["coverage"]
|
|
441
|
+
|
|
442
|
+
if path:
|
|
443
|
+
cmd += f" {path}"
|
|
444
|
+
|
|
445
|
+
threshold = threshold or self._config.get("coverage_threshold", 80)
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
result = self._run_command(cmd)
|
|
449
|
+
output = result.stdout + result.stderr
|
|
450
|
+
|
|
451
|
+
# Try to extract coverage percentage
|
|
452
|
+
coverage_pct = self._extract_coverage(output, fw)
|
|
453
|
+
|
|
454
|
+
status_msg = f"Coverage: {coverage_pct:.1f}%"
|
|
455
|
+
if coverage_pct < threshold:
|
|
456
|
+
status_msg += f" (below {threshold}% threshold)"
|
|
457
|
+
success = False
|
|
458
|
+
else:
|
|
459
|
+
status_msg += f" (meets {threshold}% threshold)"
|
|
460
|
+
success = result.returncode == 0
|
|
461
|
+
|
|
462
|
+
return ActionResult(
|
|
463
|
+
success=success,
|
|
464
|
+
message=status_msg,
|
|
465
|
+
data={
|
|
466
|
+
"framework": fw,
|
|
467
|
+
"coverage_percent": coverage_pct,
|
|
468
|
+
"threshold": threshold,
|
|
469
|
+
"output": output,
|
|
470
|
+
}
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
except Exception as e:
|
|
474
|
+
return ActionResult.fail(f"Error running coverage: {e}")
|
|
475
|
+
|
|
476
|
+
def watch_tests(
|
|
477
|
+
self,
|
|
478
|
+
path: Optional[str] = None,
|
|
479
|
+
framework: Optional[str] = None
|
|
480
|
+
) -> ActionResult:
|
|
481
|
+
"""Run tests in watch mode (for frameworks that support it)
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
path: Path to watch
|
|
485
|
+
framework: Force specific framework
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
ActionResult with watch mode info
|
|
489
|
+
"""
|
|
490
|
+
fw = self._get_framework(framework)
|
|
491
|
+
if not fw:
|
|
492
|
+
return ActionResult.fail("Could not detect testing framework.")
|
|
493
|
+
|
|
494
|
+
# Watch mode commands
|
|
495
|
+
watch_commands = {
|
|
496
|
+
"pytest": "pytest-watch",
|
|
497
|
+
"jest": "npx jest --watch",
|
|
498
|
+
"vitest": "npx vitest", # vitest has built-in watch
|
|
499
|
+
"cargo": "cargo watch -x test",
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if fw not in watch_commands:
|
|
503
|
+
return ActionResult.fail(f"Watch mode not supported for {fw}")
|
|
504
|
+
|
|
505
|
+
cmd = watch_commands[fw]
|
|
506
|
+
if path:
|
|
507
|
+
cmd += f" {path}"
|
|
508
|
+
|
|
509
|
+
return ActionResult.ok(
|
|
510
|
+
message=f"Watch mode command for {fw}",
|
|
511
|
+
data={
|
|
512
|
+
"framework": fw,
|
|
513
|
+
"command": cmd,
|
|
514
|
+
"note": "Run this command manually for continuous testing",
|
|
515
|
+
}
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
def run_failed(self, framework: Optional[str] = None) -> ActionResult:
|
|
519
|
+
"""Re-run only failed tests
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
framework: Force specific framework
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
ActionResult with rerun results
|
|
526
|
+
"""
|
|
527
|
+
fw = self._get_framework(framework)
|
|
528
|
+
if not fw:
|
|
529
|
+
return ActionResult.fail("Could not detect testing framework.")
|
|
530
|
+
|
|
531
|
+
# Failed test rerun commands
|
|
532
|
+
rerun_commands = {
|
|
533
|
+
"pytest": "pytest --lf", # --last-failed
|
|
534
|
+
"jest": "npx jest --onlyFailures",
|
|
535
|
+
"vitest": "npx vitest run --reporter=verbose",
|
|
536
|
+
"go": "go test -run 'TestFailed'", # Needs manual pattern
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if fw not in rerun_commands:
|
|
540
|
+
return ActionResult.fail(f"Rerun failed not supported for {fw}")
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
result = self._run_command(rerun_commands[fw])
|
|
544
|
+
output = result.stdout + result.stderr
|
|
545
|
+
|
|
546
|
+
return ActionResult(
|
|
547
|
+
success=result.returncode == 0,
|
|
548
|
+
message=f"Re-ran failed tests ({fw})",
|
|
549
|
+
data={
|
|
550
|
+
"framework": fw,
|
|
551
|
+
"output": output,
|
|
552
|
+
"return_code": result.returncode,
|
|
553
|
+
}
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
except Exception as e:
|
|
557
|
+
return ActionResult.fail(f"Error re-running failed tests: {e}")
|
|
558
|
+
|
|
559
|
+
def detect_framework_action(self) -> ActionResult:
|
|
560
|
+
"""Detect the testing framework
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
ActionResult with detected framework
|
|
564
|
+
"""
|
|
565
|
+
fw = self._detect_framework()
|
|
566
|
+
|
|
567
|
+
if fw:
|
|
568
|
+
config = self.FRAMEWORK_COMMANDS.get(fw, {})
|
|
569
|
+
return ActionResult.ok(
|
|
570
|
+
message=f"Detected testing framework: {fw}",
|
|
571
|
+
data={
|
|
572
|
+
"framework": fw,
|
|
573
|
+
"run_command": config.get("run", ""),
|
|
574
|
+
"file_pattern": config.get("file_pattern", ""),
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
else:
|
|
578
|
+
return ActionResult.fail(
|
|
579
|
+
"Could not detect testing framework. "
|
|
580
|
+
"Supported: pytest, jest, vitest, mocha, go test, cargo test, rspec, phpunit"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
def _parse_test_output(self, output: str, framework: str) -> tuple:
|
|
584
|
+
"""Parse test output to extract counts"""
|
|
585
|
+
passed = failed = skipped = 0
|
|
586
|
+
|
|
587
|
+
if framework == "pytest":
|
|
588
|
+
# "5 passed, 2 failed, 1 skipped"
|
|
589
|
+
match = re.search(r"(\d+) passed", output)
|
|
590
|
+
if match:
|
|
591
|
+
passed = int(match.group(1))
|
|
592
|
+
match = re.search(r"(\d+) failed", output)
|
|
593
|
+
if match:
|
|
594
|
+
failed = int(match.group(1))
|
|
595
|
+
match = re.search(r"(\d+) skipped", output)
|
|
596
|
+
if match:
|
|
597
|
+
skipped = int(match.group(1))
|
|
598
|
+
|
|
599
|
+
elif framework in ["jest", "vitest"]:
|
|
600
|
+
# "Tests: 5 passed, 2 failed, 7 total"
|
|
601
|
+
match = re.search(r"(\d+) passed", output)
|
|
602
|
+
if match:
|
|
603
|
+
passed = int(match.group(1))
|
|
604
|
+
match = re.search(r"(\d+) failed", output)
|
|
605
|
+
if match:
|
|
606
|
+
failed = int(match.group(1))
|
|
607
|
+
|
|
608
|
+
elif framework == "go":
|
|
609
|
+
# "ok" for passed, "FAIL" for failed
|
|
610
|
+
passed = output.count("ok ")
|
|
611
|
+
failed = output.count("FAIL")
|
|
612
|
+
|
|
613
|
+
elif framework == "cargo":
|
|
614
|
+
# "test result: ok. X passed; Y failed"
|
|
615
|
+
match = re.search(r"(\d+) passed", output)
|
|
616
|
+
if match:
|
|
617
|
+
passed = int(match.group(1))
|
|
618
|
+
match = re.search(r"(\d+) failed", output)
|
|
619
|
+
if match:
|
|
620
|
+
failed = int(match.group(1))
|
|
621
|
+
|
|
622
|
+
return passed, failed, skipped
|
|
623
|
+
|
|
624
|
+
def _count_tests(self, output: str, framework: str) -> int:
|
|
625
|
+
"""Count discovered tests"""
|
|
626
|
+
if framework == "pytest":
|
|
627
|
+
# Count test items
|
|
628
|
+
return output.count("<Function") + output.count("<Method")
|
|
629
|
+
elif framework in ["jest", "vitest"]:
|
|
630
|
+
return len(output.strip().split("\n")) if output.strip() else 0
|
|
631
|
+
elif framework == "go":
|
|
632
|
+
return len([l for l in output.split("\n") if l.startswith("Test")])
|
|
633
|
+
else:
|
|
634
|
+
return len(output.strip().split("\n")) if output.strip() else 0
|
|
635
|
+
|
|
636
|
+
def _extract_coverage(self, output: str, framework: str) -> float:
|
|
637
|
+
"""Extract coverage percentage from output"""
|
|
638
|
+
# Generic patterns
|
|
639
|
+
patterns = [
|
|
640
|
+
r"(\d+(?:\.\d+)?)\s*%\s*(?:total|coverage|covered)",
|
|
641
|
+
r"(?:coverage|total).*?(\d+(?:\.\d+)?)\s*%",
|
|
642
|
+
r"(\d+(?:\.\d+)?)%",
|
|
643
|
+
]
|
|
644
|
+
|
|
645
|
+
for pattern in patterns:
|
|
646
|
+
match = re.search(pattern, output, re.IGNORECASE)
|
|
647
|
+
if match:
|
|
648
|
+
return float(match.group(1))
|
|
649
|
+
|
|
650
|
+
return 0.0
|
|
651
|
+
|
|
652
|
+
def can_handle(self, request: str) -> float:
|
|
653
|
+
"""Check if request is testing-related"""
|
|
654
|
+
request_lower = request.lower()
|
|
655
|
+
|
|
656
|
+
# High confidence
|
|
657
|
+
high_conf = ["run test", "pytest", "jest", "unittest", "test coverage",
|
|
658
|
+
"run spec", "run specs", "vitest", "mocha"]
|
|
659
|
+
for kw in high_conf:
|
|
660
|
+
if kw in request_lower:
|
|
661
|
+
return 0.9
|
|
662
|
+
|
|
663
|
+
# Medium confidence
|
|
664
|
+
med_conf = ["test", "tests", "coverage", "tdd", "testing"]
|
|
665
|
+
for kw in med_conf:
|
|
666
|
+
if kw in request_lower:
|
|
667
|
+
return 0.6
|
|
668
|
+
|
|
669
|
+
return super().can_handle(request)
|
|
670
|
+
|
|
671
|
+
def handle_request(self, request: str, **kwargs) -> Optional[ActionResult]:
|
|
672
|
+
"""Handle a natural language request"""
|
|
673
|
+
request_lower = request.lower()
|
|
674
|
+
|
|
675
|
+
# Run tests
|
|
676
|
+
if any(kw in request_lower for kw in ["run test", "run the test", "execute test"]):
|
|
677
|
+
# Check for specific file
|
|
678
|
+
path_match = re.search(r"(?:test|run)\s+([^\s]+\.(?:py|js|ts|go|rs))", request_lower)
|
|
679
|
+
path = path_match.group(1) if path_match else None
|
|
680
|
+
return self.run_tests(path=path)
|
|
681
|
+
|
|
682
|
+
# Coverage
|
|
683
|
+
if "coverage" in request_lower:
|
|
684
|
+
return self.run_coverage()
|
|
685
|
+
|
|
686
|
+
# Discover
|
|
687
|
+
if any(kw in request_lower for kw in ["find test", "list test", "discover test", "what test"]):
|
|
688
|
+
return self.discover_tests()
|
|
689
|
+
|
|
690
|
+
# Failed tests
|
|
691
|
+
if any(kw in request_lower for kw in ["failed test", "rerun failed", "run failed"]):
|
|
692
|
+
return self.run_failed()
|
|
693
|
+
|
|
694
|
+
# Detect framework
|
|
695
|
+
if any(kw in request_lower for kw in ["which framework", "detect framework", "test framework"]):
|
|
696
|
+
return self.detect_framework_action()
|
|
697
|
+
|
|
698
|
+
# Default: run tests
|
|
699
|
+
if "test" in request_lower:
|
|
700
|
+
return self.run_tests()
|
|
701
|
+
|
|
702
|
+
return None
|