devloop 0.2.0__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.
- devloop/__init__.py +3 -0
- devloop/agents/__init__.py +33 -0
- devloop/agents/agent_health_monitor.py +105 -0
- devloop/agents/ci_monitor.py +237 -0
- devloop/agents/code_rabbit.py +248 -0
- devloop/agents/doc_lifecycle.py +374 -0
- devloop/agents/echo.py +24 -0
- devloop/agents/file_logger.py +46 -0
- devloop/agents/formatter.py +511 -0
- devloop/agents/git_commit_assistant.py +421 -0
- devloop/agents/linter.py +399 -0
- devloop/agents/performance_profiler.py +284 -0
- devloop/agents/security_scanner.py +322 -0
- devloop/agents/snyk.py +292 -0
- devloop/agents/test_runner.py +484 -0
- devloop/agents/type_checker.py +242 -0
- devloop/cli/__init__.py +1 -0
- devloop/cli/commands/__init__.py +1 -0
- devloop/cli/commands/custom_agents.py +144 -0
- devloop/cli/commands/feedback.py +161 -0
- devloop/cli/commands/summary.py +50 -0
- devloop/cli/main.py +430 -0
- devloop/cli/main_v1.py +144 -0
- devloop/collectors/__init__.py +17 -0
- devloop/collectors/base.py +55 -0
- devloop/collectors/filesystem.py +126 -0
- devloop/collectors/git.py +171 -0
- devloop/collectors/manager.py +159 -0
- devloop/collectors/process.py +221 -0
- devloop/collectors/system.py +195 -0
- devloop/core/__init__.py +21 -0
- devloop/core/agent.py +206 -0
- devloop/core/agent_template.py +498 -0
- devloop/core/amp_integration.py +166 -0
- devloop/core/auto_fix.py +224 -0
- devloop/core/config.py +272 -0
- devloop/core/context.py +0 -0
- devloop/core/context_store.py +530 -0
- devloop/core/contextual_feedback.py +311 -0
- devloop/core/custom_agent.py +439 -0
- devloop/core/debug_trace.py +289 -0
- devloop/core/event.py +105 -0
- devloop/core/event_store.py +316 -0
- devloop/core/feedback.py +311 -0
- devloop/core/learning.py +351 -0
- devloop/core/manager.py +219 -0
- devloop/core/performance.py +433 -0
- devloop/core/proactive_feedback.py +302 -0
- devloop/core/summary_formatter.py +159 -0
- devloop/core/summary_generator.py +275 -0
- devloop-0.2.0.dist-info/METADATA +705 -0
- devloop-0.2.0.dist-info/RECORD +55 -0
- devloop-0.2.0.dist-info/WHEEL +4 -0
- devloop-0.2.0.dist-info/entry_points.txt +3 -0
- devloop-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Test runner agent - runs tests on file changes."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from devloop.core.agent import Agent, AgentResult
|
|
11
|
+
from devloop.core.context_store import Finding, Severity
|
|
12
|
+
from devloop.core.event import Event
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestRunnerConfig:
|
|
16
|
+
"""Configuration for TestRunnerAgent."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, config: Dict[str, Any]):
|
|
19
|
+
self.enabled = config.get("enabled", True)
|
|
20
|
+
self.run_on_save = config.get("runOnSave", True)
|
|
21
|
+
self.related_tests_only = config.get("relatedTestsOnly", True)
|
|
22
|
+
self.auto_detect_frameworks = config.get("autoDetectFrameworks", True)
|
|
23
|
+
|
|
24
|
+
# Default frameworks (will be overridden by auto-detection if enabled)
|
|
25
|
+
self.test_frameworks = config.get(
|
|
26
|
+
"testFrameworks",
|
|
27
|
+
{"python": "pytest", "javascript": "jest", "typescript": "jest"},
|
|
28
|
+
)
|
|
29
|
+
self.test_patterns = config.get(
|
|
30
|
+
"testPatterns",
|
|
31
|
+
{
|
|
32
|
+
"python": ["**/test_*.py", "**/*_test.py"],
|
|
33
|
+
"javascript": ["**/*.test.js", "**/*.spec.js"],
|
|
34
|
+
"typescript": ["**/*.test.ts", "**/*.spec.ts"],
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Auto-detect available frameworks if enabled
|
|
39
|
+
if self.auto_detect_frameworks:
|
|
40
|
+
detected = self._auto_detect_frameworks()
|
|
41
|
+
self.test_frameworks.update(detected)
|
|
42
|
+
|
|
43
|
+
def _auto_detect_frameworks(self) -> Dict[str, str]:
|
|
44
|
+
"""Auto-detect available test frameworks in the project."""
|
|
45
|
+
detected = {}
|
|
46
|
+
project_root = Path.cwd()
|
|
47
|
+
|
|
48
|
+
# Python frameworks
|
|
49
|
+
if (project_root / "pytest.ini").exists() or (
|
|
50
|
+
project_root / "pyproject.toml"
|
|
51
|
+
).exists():
|
|
52
|
+
if self._check_command("python -m pytest --version"):
|
|
53
|
+
detected["python"] = "pytest"
|
|
54
|
+
|
|
55
|
+
if (project_root / "requirements.txt").exists():
|
|
56
|
+
with open(project_root / "requirements.txt") as f:
|
|
57
|
+
if "unittest" in f.read().lower() or "unittest" in str(project_root):
|
|
58
|
+
detected["python"] = "unittest"
|
|
59
|
+
|
|
60
|
+
# JavaScript/TypeScript frameworks
|
|
61
|
+
if (project_root / "package.json").exists():
|
|
62
|
+
try:
|
|
63
|
+
with open(project_root / "package.json") as f:
|
|
64
|
+
package_data = json.load(f)
|
|
65
|
+
|
|
66
|
+
scripts = package_data.get("scripts", {})
|
|
67
|
+
dependencies = package_data.get("devDependencies", {})
|
|
68
|
+
|
|
69
|
+
if "jest" in dependencies or any(
|
|
70
|
+
"jest" in str(v) for v in scripts.values()
|
|
71
|
+
):
|
|
72
|
+
detected["javascript"] = "jest"
|
|
73
|
+
detected["typescript"] = "jest"
|
|
74
|
+
|
|
75
|
+
if "mocha" in dependencies:
|
|
76
|
+
detected["javascript"] = "mocha"
|
|
77
|
+
|
|
78
|
+
if "vitest" in dependencies:
|
|
79
|
+
detected["javascript"] = "vitest"
|
|
80
|
+
detected["typescript"] = "vitest"
|
|
81
|
+
|
|
82
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# Fallback to basic detection
|
|
86
|
+
if not detected.get("python"):
|
|
87
|
+
if self._check_command("python -m pytest --version"):
|
|
88
|
+
detected["python"] = "pytest"
|
|
89
|
+
elif self._check_command("python -m unittest --help > /dev/null 2>&1"):
|
|
90
|
+
detected["python"] = "unittest"
|
|
91
|
+
|
|
92
|
+
return detected
|
|
93
|
+
|
|
94
|
+
def _check_command(self, command: str) -> bool:
|
|
95
|
+
"""Check if a command is available."""
|
|
96
|
+
import shutil
|
|
97
|
+
|
|
98
|
+
return shutil.which(command) is not None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestResult:
|
|
102
|
+
"""Result from running tests."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
success: bool,
|
|
107
|
+
passed: int = 0,
|
|
108
|
+
failed: int = 0,
|
|
109
|
+
skipped: int = 0,
|
|
110
|
+
duration: float = 0.0,
|
|
111
|
+
failures: List[Dict[str, Any]] | None = None,
|
|
112
|
+
error: str | None = None,
|
|
113
|
+
):
|
|
114
|
+
self.success = success
|
|
115
|
+
self.passed = passed
|
|
116
|
+
self.failed = failed
|
|
117
|
+
self.skipped = skipped
|
|
118
|
+
self.duration = duration
|
|
119
|
+
self.failures = failures or []
|
|
120
|
+
self.error = error
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def total(self) -> int:
|
|
124
|
+
"""Total tests run."""
|
|
125
|
+
return self.passed + self.failed + self.skipped
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def all_passed(self) -> bool:
|
|
129
|
+
"""Check if all tests passed."""
|
|
130
|
+
return self.failed == 0 and self.passed > 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TestRunnerAgent(Agent):
|
|
134
|
+
"""Agent that runs tests when files change."""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
name: str,
|
|
139
|
+
triggers: List[str],
|
|
140
|
+
event_bus,
|
|
141
|
+
config: Dict[str, Any] | None = None,
|
|
142
|
+
):
|
|
143
|
+
super().__init__(name, triggers, event_bus)
|
|
144
|
+
self.config = TestRunnerConfig(config or {})
|
|
145
|
+
|
|
146
|
+
async def handle(self, event: Event) -> AgentResult:
|
|
147
|
+
"""Handle file change event by running tests."""
|
|
148
|
+
if not self.config.run_on_save:
|
|
149
|
+
return AgentResult(
|
|
150
|
+
agent_name=self.name,
|
|
151
|
+
success=True,
|
|
152
|
+
duration=0,
|
|
153
|
+
message="Run on save disabled",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Extract file path
|
|
157
|
+
file_path = event.payload.get("path")
|
|
158
|
+
if not file_path:
|
|
159
|
+
return AgentResult(
|
|
160
|
+
agent_name=self.name,
|
|
161
|
+
success=True,
|
|
162
|
+
duration=0,
|
|
163
|
+
message="No file path in event",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
path = Path(file_path)
|
|
167
|
+
|
|
168
|
+
# Determine if this is a test file or source file
|
|
169
|
+
is_test_file = self._is_test_file(path)
|
|
170
|
+
|
|
171
|
+
# Get test framework
|
|
172
|
+
framework = self._get_test_framework(path)
|
|
173
|
+
if not framework:
|
|
174
|
+
return AgentResult(
|
|
175
|
+
agent_name=self.name,
|
|
176
|
+
success=True,
|
|
177
|
+
duration=0,
|
|
178
|
+
message=f"No test framework configured for {path.suffix}",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Determine which tests to run
|
|
182
|
+
if is_test_file:
|
|
183
|
+
# Run this specific test file
|
|
184
|
+
test_files = [path]
|
|
185
|
+
elif self.config.related_tests_only:
|
|
186
|
+
# Find related test files
|
|
187
|
+
test_files = self._find_related_tests(path)
|
|
188
|
+
if not test_files:
|
|
189
|
+
return AgentResult(
|
|
190
|
+
agent_name=self.name,
|
|
191
|
+
success=True,
|
|
192
|
+
duration=0,
|
|
193
|
+
message=f"No tests found for {path.name}",
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
# Run all tests
|
|
197
|
+
test_files = []
|
|
198
|
+
|
|
199
|
+
# Run tests
|
|
200
|
+
result = await self._run_tests(framework, test_files, path)
|
|
201
|
+
|
|
202
|
+
# Build result message
|
|
203
|
+
if result.error:
|
|
204
|
+
message = f"Test error: {result.error}"
|
|
205
|
+
success = False
|
|
206
|
+
elif result.all_passed:
|
|
207
|
+
message = f"✓ {result.passed} test(s) passed"
|
|
208
|
+
success = True
|
|
209
|
+
elif result.failed > 0:
|
|
210
|
+
message = f"✗ {result.failed} test(s) failed, {result.passed} passed"
|
|
211
|
+
success = False
|
|
212
|
+
else:
|
|
213
|
+
message = "No tests run"
|
|
214
|
+
success = True
|
|
215
|
+
|
|
216
|
+
agent_result = AgentResult(
|
|
217
|
+
agent_name=self.name,
|
|
218
|
+
success=success,
|
|
219
|
+
duration=result.duration,
|
|
220
|
+
message=message,
|
|
221
|
+
data={
|
|
222
|
+
"file": str(path),
|
|
223
|
+
"framework": framework,
|
|
224
|
+
"passed": result.passed,
|
|
225
|
+
"failed": result.failed,
|
|
226
|
+
"skipped": result.skipped,
|
|
227
|
+
"total": result.total,
|
|
228
|
+
"failures": result.failures,
|
|
229
|
+
},
|
|
230
|
+
error=result.error,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Write to context store for Claude Code integration
|
|
234
|
+
await self._write_findings_to_context(path, result, framework)
|
|
235
|
+
|
|
236
|
+
return agent_result
|
|
237
|
+
|
|
238
|
+
async def _write_findings_to_context(
|
|
239
|
+
self, path: Path, test_result: TestResult, framework: str
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Write test failures to the context store."""
|
|
242
|
+
from devloop.core.context_store import context_store
|
|
243
|
+
|
|
244
|
+
if test_result.failed > 0:
|
|
245
|
+
# Create a finding for test failures
|
|
246
|
+
finding = Finding(
|
|
247
|
+
id=f"{self.name}-{path}-failed",
|
|
248
|
+
agent=self.name,
|
|
249
|
+
timestamp=str(datetime.now()),
|
|
250
|
+
file=str(path),
|
|
251
|
+
severity=Severity.ERROR,
|
|
252
|
+
message=f"{test_result.failed} test(s) failed in {framework}",
|
|
253
|
+
context={
|
|
254
|
+
"framework": framework,
|
|
255
|
+
"failed": test_result.failed,
|
|
256
|
+
"passed": test_result.passed,
|
|
257
|
+
"blocking": True,
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
await context_store.add_finding(finding)
|
|
261
|
+
elif test_result.error:
|
|
262
|
+
# Create a finding for test errors
|
|
263
|
+
finding = Finding(
|
|
264
|
+
id=f"{self.name}-{path}-error",
|
|
265
|
+
agent=self.name,
|
|
266
|
+
timestamp=str(datetime.now()),
|
|
267
|
+
file=str(path),
|
|
268
|
+
severity=Severity.ERROR,
|
|
269
|
+
message=f"Test error: {test_result.error}",
|
|
270
|
+
context={
|
|
271
|
+
"framework": framework,
|
|
272
|
+
"error": test_result.error,
|
|
273
|
+
"blocking": True,
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
await context_store.add_finding(finding)
|
|
277
|
+
|
|
278
|
+
def _is_test_file(self, path: Path) -> bool:
|
|
279
|
+
"""Check if file is a test file."""
|
|
280
|
+
name = path.name
|
|
281
|
+
return (
|
|
282
|
+
name.startswith("test_")
|
|
283
|
+
or name.endswith("_test.py")
|
|
284
|
+
or ".test." in name
|
|
285
|
+
or ".spec." in name
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def _get_test_framework(self, path: Path) -> Optional[str]:
|
|
289
|
+
"""Get test framework for file type."""
|
|
290
|
+
suffix = path.suffix.lstrip(".")
|
|
291
|
+
|
|
292
|
+
extension_map = {
|
|
293
|
+
"py": "python",
|
|
294
|
+
"js": "javascript",
|
|
295
|
+
"jsx": "javascript",
|
|
296
|
+
"ts": "typescript",
|
|
297
|
+
"tsx": "typescript",
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
language = extension_map.get(suffix)
|
|
301
|
+
if language:
|
|
302
|
+
return self.config.test_frameworks.get(language)
|
|
303
|
+
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
def _find_related_tests(self, path: Path) -> List[Path]:
|
|
307
|
+
"""Find test files related to a source file."""
|
|
308
|
+
test_files = []
|
|
309
|
+
|
|
310
|
+
# For Python: test_<name>.py or <name>_test.py
|
|
311
|
+
if path.suffix == ".py":
|
|
312
|
+
stem = path.stem
|
|
313
|
+
test_dir = path.parent / "tests"
|
|
314
|
+
possible_tests = [
|
|
315
|
+
path.parent / f"test_{stem}.py",
|
|
316
|
+
path.parent / f"{stem}_test.py",
|
|
317
|
+
test_dir / f"test_{stem}.py",
|
|
318
|
+
test_dir / f"{stem}_test.py",
|
|
319
|
+
]
|
|
320
|
+
test_files.extend([t for t in possible_tests if t.exists()])
|
|
321
|
+
|
|
322
|
+
# For JS/TS: <name>.test.js/ts or <name>.spec.js/ts
|
|
323
|
+
elif path.suffix in [".js", ".jsx", ".ts", ".tsx"]:
|
|
324
|
+
stem = path.stem
|
|
325
|
+
ext = path.suffix
|
|
326
|
+
possible_tests = [
|
|
327
|
+
path.parent / f"{stem}.test{ext}",
|
|
328
|
+
path.parent / f"{stem}.spec{ext}",
|
|
329
|
+
path.parent / "__tests__" / f"{stem}.test{ext}",
|
|
330
|
+
]
|
|
331
|
+
test_files.extend([t for t in possible_tests if t.exists()])
|
|
332
|
+
|
|
333
|
+
return test_files
|
|
334
|
+
|
|
335
|
+
async def _run_tests(
|
|
336
|
+
self, framework: str, test_files: List[Path], source_path: Path
|
|
337
|
+
) -> TestResult:
|
|
338
|
+
"""Run tests using the specified framework."""
|
|
339
|
+
try:
|
|
340
|
+
if framework == "pytest":
|
|
341
|
+
return await self._run_pytest(test_files, source_path)
|
|
342
|
+
elif framework == "jest":
|
|
343
|
+
return await self._run_jest(test_files, source_path)
|
|
344
|
+
else:
|
|
345
|
+
return TestResult(
|
|
346
|
+
success=False, error=f"Unknown framework: {framework}"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
except Exception as e:
|
|
350
|
+
self.logger.error(f"Error running {framework}: {e}")
|
|
351
|
+
return TestResult(success=False, error=str(e))
|
|
352
|
+
|
|
353
|
+
async def _run_pytest(
|
|
354
|
+
self, test_files: List[Path], source_path: Path
|
|
355
|
+
) -> TestResult:
|
|
356
|
+
"""Run pytest."""
|
|
357
|
+
try:
|
|
358
|
+
# Get updated environment with venv bin in PATH
|
|
359
|
+
import os
|
|
360
|
+
|
|
361
|
+
env = os.environ.copy()
|
|
362
|
+
venv_bin = Path(__file__).parent.parent.parent.parent / ".venv" / "bin"
|
|
363
|
+
if venv_bin.exists():
|
|
364
|
+
env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
|
|
365
|
+
|
|
366
|
+
# Check if pytest is installed
|
|
367
|
+
check = await asyncio.create_subprocess_exec(
|
|
368
|
+
"pytest",
|
|
369
|
+
"--version",
|
|
370
|
+
stdout=asyncio.subprocess.PIPE,
|
|
371
|
+
stderr=asyncio.subprocess.PIPE,
|
|
372
|
+
env=env,
|
|
373
|
+
)
|
|
374
|
+
await check.communicate()
|
|
375
|
+
|
|
376
|
+
if check.returncode != 0:
|
|
377
|
+
return TestResult(success=False, error="pytest not installed")
|
|
378
|
+
|
|
379
|
+
# Build command
|
|
380
|
+
cmd = ["pytest", "-v", "--tb=short"]
|
|
381
|
+
|
|
382
|
+
if test_files:
|
|
383
|
+
# Run specific test files
|
|
384
|
+
cmd.extend([str(f) for f in test_files])
|
|
385
|
+
else:
|
|
386
|
+
# Run all tests in project
|
|
387
|
+
cmd.append(str(source_path.parent))
|
|
388
|
+
|
|
389
|
+
# Run pytest
|
|
390
|
+
proc = await asyncio.create_subprocess_exec(
|
|
391
|
+
*cmd,
|
|
392
|
+
stdout=asyncio.subprocess.PIPE,
|
|
393
|
+
stderr=asyncio.subprocess.PIPE,
|
|
394
|
+
env=env,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
stdout, stderr = await proc.communicate()
|
|
398
|
+
output = stdout.decode() if stdout else ""
|
|
399
|
+
|
|
400
|
+
# Parse output
|
|
401
|
+
passed = self._count_pattern(output, r"(\d+) passed")
|
|
402
|
+
failed = self._count_pattern(output, r"(\d+) failed")
|
|
403
|
+
skipped = self._count_pattern(output, r"(\d+) skipped")
|
|
404
|
+
|
|
405
|
+
# Extract duration
|
|
406
|
+
duration_match = re.search(r"in ([\d.]+)s", output)
|
|
407
|
+
duration = float(duration_match.group(1)) if duration_match else 0.0
|
|
408
|
+
|
|
409
|
+
success = proc.returncode == 0
|
|
410
|
+
|
|
411
|
+
return TestResult(
|
|
412
|
+
success=success,
|
|
413
|
+
passed=passed,
|
|
414
|
+
failed=failed,
|
|
415
|
+
skipped=skipped,
|
|
416
|
+
duration=duration,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
except FileNotFoundError:
|
|
420
|
+
return TestResult(success=False, error="pytest command not found")
|
|
421
|
+
|
|
422
|
+
async def _run_jest(self, test_files: List[Path], source_path: Path) -> TestResult:
|
|
423
|
+
"""Run jest."""
|
|
424
|
+
try:
|
|
425
|
+
# Get updated environment with venv bin in PATH
|
|
426
|
+
import os
|
|
427
|
+
|
|
428
|
+
env = os.environ.copy()
|
|
429
|
+
venv_bin = Path(__file__).parent.parent.parent.parent / ".venv" / "bin"
|
|
430
|
+
if venv_bin.exists():
|
|
431
|
+
env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
|
|
432
|
+
|
|
433
|
+
# Check if jest is installed
|
|
434
|
+
check = await asyncio.create_subprocess_exec(
|
|
435
|
+
"jest",
|
|
436
|
+
"--version",
|
|
437
|
+
stdout=asyncio.subprocess.PIPE,
|
|
438
|
+
stderr=asyncio.subprocess.PIPE,
|
|
439
|
+
env=env,
|
|
440
|
+
)
|
|
441
|
+
await check.communicate()
|
|
442
|
+
|
|
443
|
+
if check.returncode != 0:
|
|
444
|
+
return TestResult(success=False, error="jest not installed")
|
|
445
|
+
|
|
446
|
+
# Build command
|
|
447
|
+
cmd = ["jest", "--json"]
|
|
448
|
+
|
|
449
|
+
if test_files:
|
|
450
|
+
cmd.extend([str(f) for f in test_files])
|
|
451
|
+
|
|
452
|
+
# Run jest
|
|
453
|
+
proc = await asyncio.create_subprocess_exec(
|
|
454
|
+
*cmd,
|
|
455
|
+
stdout=asyncio.subprocess.PIPE,
|
|
456
|
+
stderr=asyncio.subprocess.PIPE,
|
|
457
|
+
env=env,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
stdout, stderr = await proc.communicate()
|
|
461
|
+
|
|
462
|
+
# Parse JSON output
|
|
463
|
+
if stdout:
|
|
464
|
+
try:
|
|
465
|
+
results = json.loads(stdout.decode())
|
|
466
|
+
return TestResult(
|
|
467
|
+
success=results.get("success", False),
|
|
468
|
+
passed=results.get("numPassedTests", 0),
|
|
469
|
+
failed=results.get("numFailedTests", 0),
|
|
470
|
+
skipped=results.get("numPendingTests", 0),
|
|
471
|
+
duration=results.get("startTime", 0),
|
|
472
|
+
)
|
|
473
|
+
except json.JSONDecodeError:
|
|
474
|
+
pass
|
|
475
|
+
|
|
476
|
+
return TestResult(success=proc.returncode == 0)
|
|
477
|
+
|
|
478
|
+
except FileNotFoundError:
|
|
479
|
+
return TestResult(success=False, error="jest command not found")
|
|
480
|
+
|
|
481
|
+
def _count_pattern(self, text: str, pattern: str) -> int:
|
|
482
|
+
"""Count occurrences of a pattern in text."""
|
|
483
|
+
match = re.search(pattern, text)
|
|
484
|
+
return int(match.group(1)) if match else 0
|