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.
@@ -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