lucidscan 0.5.12__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.
- lucidscan/__init__.py +12 -0
- lucidscan/bootstrap/__init__.py +26 -0
- lucidscan/bootstrap/paths.py +160 -0
- lucidscan/bootstrap/platform.py +111 -0
- lucidscan/bootstrap/validation.py +76 -0
- lucidscan/bootstrap/versions.py +119 -0
- lucidscan/cli/__init__.py +50 -0
- lucidscan/cli/__main__.py +8 -0
- lucidscan/cli/arguments.py +405 -0
- lucidscan/cli/commands/__init__.py +64 -0
- lucidscan/cli/commands/autoconfigure.py +294 -0
- lucidscan/cli/commands/help.py +69 -0
- lucidscan/cli/commands/init.py +656 -0
- lucidscan/cli/commands/list_scanners.py +59 -0
- lucidscan/cli/commands/scan.py +307 -0
- lucidscan/cli/commands/serve.py +142 -0
- lucidscan/cli/commands/status.py +84 -0
- lucidscan/cli/commands/validate.py +105 -0
- lucidscan/cli/config_bridge.py +152 -0
- lucidscan/cli/exit_codes.py +17 -0
- lucidscan/cli/runner.py +284 -0
- lucidscan/config/__init__.py +29 -0
- lucidscan/config/ignore.py +178 -0
- lucidscan/config/loader.py +431 -0
- lucidscan/config/models.py +316 -0
- lucidscan/config/validation.py +645 -0
- lucidscan/core/__init__.py +3 -0
- lucidscan/core/domain_runner.py +463 -0
- lucidscan/core/git.py +174 -0
- lucidscan/core/logging.py +34 -0
- lucidscan/core/models.py +207 -0
- lucidscan/core/streaming.py +340 -0
- lucidscan/core/subprocess_runner.py +164 -0
- lucidscan/detection/__init__.py +21 -0
- lucidscan/detection/detector.py +154 -0
- lucidscan/detection/frameworks.py +270 -0
- lucidscan/detection/languages.py +328 -0
- lucidscan/detection/tools.py +229 -0
- lucidscan/generation/__init__.py +15 -0
- lucidscan/generation/config_generator.py +275 -0
- lucidscan/generation/package_installer.py +330 -0
- lucidscan/mcp/__init__.py +20 -0
- lucidscan/mcp/formatter.py +510 -0
- lucidscan/mcp/server.py +297 -0
- lucidscan/mcp/tools.py +1049 -0
- lucidscan/mcp/watcher.py +237 -0
- lucidscan/pipeline/__init__.py +17 -0
- lucidscan/pipeline/executor.py +187 -0
- lucidscan/pipeline/parallel.py +181 -0
- lucidscan/plugins/__init__.py +40 -0
- lucidscan/plugins/coverage/__init__.py +28 -0
- lucidscan/plugins/coverage/base.py +160 -0
- lucidscan/plugins/coverage/coverage_py.py +454 -0
- lucidscan/plugins/coverage/istanbul.py +411 -0
- lucidscan/plugins/discovery.py +107 -0
- lucidscan/plugins/enrichers/__init__.py +61 -0
- lucidscan/plugins/enrichers/base.py +63 -0
- lucidscan/plugins/linters/__init__.py +26 -0
- lucidscan/plugins/linters/base.py +125 -0
- lucidscan/plugins/linters/biome.py +448 -0
- lucidscan/plugins/linters/checkstyle.py +393 -0
- lucidscan/plugins/linters/eslint.py +368 -0
- lucidscan/plugins/linters/ruff.py +498 -0
- lucidscan/plugins/reporters/__init__.py +45 -0
- lucidscan/plugins/reporters/base.py +30 -0
- lucidscan/plugins/reporters/json_reporter.py +79 -0
- lucidscan/plugins/reporters/sarif_reporter.py +303 -0
- lucidscan/plugins/reporters/summary_reporter.py +61 -0
- lucidscan/plugins/reporters/table_reporter.py +81 -0
- lucidscan/plugins/scanners/__init__.py +57 -0
- lucidscan/plugins/scanners/base.py +60 -0
- lucidscan/plugins/scanners/checkov.py +484 -0
- lucidscan/plugins/scanners/opengrep.py +464 -0
- lucidscan/plugins/scanners/trivy.py +492 -0
- lucidscan/plugins/test_runners/__init__.py +27 -0
- lucidscan/plugins/test_runners/base.py +111 -0
- lucidscan/plugins/test_runners/jest.py +381 -0
- lucidscan/plugins/test_runners/karma.py +481 -0
- lucidscan/plugins/test_runners/playwright.py +434 -0
- lucidscan/plugins/test_runners/pytest.py +598 -0
- lucidscan/plugins/type_checkers/__init__.py +27 -0
- lucidscan/plugins/type_checkers/base.py +106 -0
- lucidscan/plugins/type_checkers/mypy.py +355 -0
- lucidscan/plugins/type_checkers/pyright.py +313 -0
- lucidscan/plugins/type_checkers/typescript.py +280 -0
- lucidscan-0.5.12.dist-info/METADATA +242 -0
- lucidscan-0.5.12.dist-info/RECORD +91 -0
- lucidscan-0.5.12.dist-info/WHEEL +5 -0
- lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
- lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
- lucidscan-0.5.12.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Karma test runner plugin.
|
|
2
|
+
|
|
3
|
+
Karma is a test runner for JavaScript that works with Jasmine,
|
|
4
|
+
commonly used with Angular applications.
|
|
5
|
+
https://karma-runner.github.io/
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from lucidscan.core.logging import get_logger
|
|
19
|
+
from lucidscan.core.models import (
|
|
20
|
+
ScanContext,
|
|
21
|
+
Severity,
|
|
22
|
+
ToolDomain,
|
|
23
|
+
UnifiedIssue,
|
|
24
|
+
)
|
|
25
|
+
from lucidscan.plugins.test_runners.base import TestRunnerPlugin, TestResult
|
|
26
|
+
|
|
27
|
+
LOGGER = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class KarmaRunner(TestRunnerPlugin):
|
|
31
|
+
"""Karma test runner plugin for Angular/JavaScript test execution."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, project_root: Optional[Path] = None):
|
|
34
|
+
"""Initialize KarmaRunner.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
project_root: Optional project root for finding Karma installation.
|
|
38
|
+
"""
|
|
39
|
+
self._project_root = project_root
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
"""Plugin identifier."""
|
|
44
|
+
return "karma"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def languages(self) -> List[str]:
|
|
48
|
+
"""Supported languages."""
|
|
49
|
+
return ["javascript", "typescript"]
|
|
50
|
+
|
|
51
|
+
def get_version(self) -> str:
|
|
52
|
+
"""Get Karma version.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Version string or 'unknown' if unable to determine.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
binary = self.ensure_binary()
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
[str(binary), "--version"],
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
encoding="utf-8",
|
|
64
|
+
errors="replace",
|
|
65
|
+
timeout=30,
|
|
66
|
+
)
|
|
67
|
+
if result.returncode == 0:
|
|
68
|
+
return result.stdout.strip()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
return "unknown"
|
|
72
|
+
|
|
73
|
+
def ensure_binary(self) -> Path:
|
|
74
|
+
"""Ensure Karma is available.
|
|
75
|
+
|
|
76
|
+
Checks for Karma in:
|
|
77
|
+
1. Project's node_modules/.bin/karma
|
|
78
|
+
2. System PATH (globally installed)
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Path to Karma binary.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
FileNotFoundError: If Karma is not installed.
|
|
85
|
+
"""
|
|
86
|
+
# Check project node_modules first
|
|
87
|
+
if self._project_root:
|
|
88
|
+
node_karma = self._project_root / "node_modules" / ".bin" / "karma"
|
|
89
|
+
if node_karma.exists():
|
|
90
|
+
return node_karma
|
|
91
|
+
|
|
92
|
+
# Check system PATH
|
|
93
|
+
karma_path = shutil.which("karma")
|
|
94
|
+
if karma_path:
|
|
95
|
+
return Path(karma_path)
|
|
96
|
+
|
|
97
|
+
raise FileNotFoundError(
|
|
98
|
+
"Karma is not installed. Install it with:\n"
|
|
99
|
+
" npm install karma --save-dev\n"
|
|
100
|
+
" OR\n"
|
|
101
|
+
" npm install -g karma-cli"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def run_tests(self, context: ScanContext) -> TestResult:
|
|
105
|
+
"""Run Karma on the specified paths.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
context: Scan context with paths and configuration.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
TestResult with test statistics and issues for failures.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
binary = self.ensure_binary()
|
|
115
|
+
except FileNotFoundError as e:
|
|
116
|
+
LOGGER.warning(str(e))
|
|
117
|
+
return TestResult()
|
|
118
|
+
|
|
119
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
120
|
+
report_file = Path(tmpdir) / "karma-results.json"
|
|
121
|
+
|
|
122
|
+
# Find karma config file
|
|
123
|
+
karma_config = self._find_karma_config(context.project_root)
|
|
124
|
+
|
|
125
|
+
cmd = [
|
|
126
|
+
str(binary),
|
|
127
|
+
"start",
|
|
128
|
+
"--single-run",
|
|
129
|
+
"--no-auto-watch",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
if karma_config:
|
|
133
|
+
cmd.append(str(karma_config))
|
|
134
|
+
|
|
135
|
+
# Set environment variable for JSON reporter output
|
|
136
|
+
env = {
|
|
137
|
+
"KARMA_JSON_REPORTER_OUTPUT": str(report_file),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
import os
|
|
144
|
+
full_env = os.environ.copy()
|
|
145
|
+
full_env.update(env)
|
|
146
|
+
|
|
147
|
+
result = subprocess.run(
|
|
148
|
+
cmd,
|
|
149
|
+
capture_output=True,
|
|
150
|
+
text=True,
|
|
151
|
+
encoding="utf-8",
|
|
152
|
+
errors="replace",
|
|
153
|
+
cwd=str(context.project_root),
|
|
154
|
+
timeout=600, # 10 minute timeout for test runs
|
|
155
|
+
env=full_env,
|
|
156
|
+
)
|
|
157
|
+
except subprocess.TimeoutExpired:
|
|
158
|
+
LOGGER.warning("Karma timed out after 600 seconds")
|
|
159
|
+
return TestResult()
|
|
160
|
+
except Exception as e:
|
|
161
|
+
LOGGER.error(f"Failed to run Karma: {e}")
|
|
162
|
+
return TestResult()
|
|
163
|
+
|
|
164
|
+
# Parse JSON report if karma-json-reporter was used
|
|
165
|
+
if report_file.exists():
|
|
166
|
+
return self._parse_json_report(report_file, context.project_root)
|
|
167
|
+
|
|
168
|
+
# Fallback: parse stdout for basic results
|
|
169
|
+
return self._parse_stdout(result.stdout, result.stderr, context.project_root)
|
|
170
|
+
|
|
171
|
+
def _find_karma_config(self, project_root: Path) -> Optional[Path]:
|
|
172
|
+
"""Find Karma configuration file.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
project_root: Project root directory.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Path to karma config or None.
|
|
179
|
+
"""
|
|
180
|
+
config_names = [
|
|
181
|
+
"karma.conf.js",
|
|
182
|
+
"karma.conf.ts",
|
|
183
|
+
"karma.config.js",
|
|
184
|
+
"karma.config.ts",
|
|
185
|
+
]
|
|
186
|
+
for name in config_names:
|
|
187
|
+
config_path = project_root / name
|
|
188
|
+
if config_path.exists():
|
|
189
|
+
return config_path
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def _parse_json_report(
|
|
193
|
+
self,
|
|
194
|
+
report_file: Path,
|
|
195
|
+
project_root: Path,
|
|
196
|
+
) -> TestResult:
|
|
197
|
+
"""Parse Karma JSON reporter output.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
report_file: Path to JSON report file.
|
|
201
|
+
project_root: Project root directory.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
TestResult with parsed data.
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
with open(report_file) as f:
|
|
208
|
+
report = json.load(f)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
LOGGER.error(f"Failed to parse Karma JSON report: {e}")
|
|
211
|
+
return TestResult()
|
|
212
|
+
|
|
213
|
+
return self._process_report(report, project_root)
|
|
214
|
+
|
|
215
|
+
def _process_report(
|
|
216
|
+
self,
|
|
217
|
+
report: Dict[str, Any],
|
|
218
|
+
project_root: Path,
|
|
219
|
+
) -> TestResult:
|
|
220
|
+
"""Process Karma JSON report.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
report: Parsed JSON report.
|
|
224
|
+
project_root: Project root directory.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
TestResult with processed data.
|
|
228
|
+
"""
|
|
229
|
+
summary = report.get("summary", {})
|
|
230
|
+
num_passed = summary.get("success", 0)
|
|
231
|
+
num_failed = summary.get("failed", 0)
|
|
232
|
+
num_skipped = summary.get("skipped", 0)
|
|
233
|
+
num_errors = summary.get("error", 0)
|
|
234
|
+
|
|
235
|
+
# Get duration if available
|
|
236
|
+
duration_ms = 0
|
|
237
|
+
if "totalTime" in summary:
|
|
238
|
+
duration_ms = int(summary["totalTime"])
|
|
239
|
+
|
|
240
|
+
result = TestResult(
|
|
241
|
+
passed=num_passed,
|
|
242
|
+
failed=num_failed,
|
|
243
|
+
skipped=num_skipped,
|
|
244
|
+
errors=num_errors,
|
|
245
|
+
duration_ms=duration_ms,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Process browsers results
|
|
249
|
+
browsers = report.get("browsers", {})
|
|
250
|
+
for browser_name, browser_results in browsers.items():
|
|
251
|
+
for test_result in browser_results.get("results", []):
|
|
252
|
+
if not test_result.get("success", True):
|
|
253
|
+
issue = self._test_to_issue(test_result, project_root)
|
|
254
|
+
if issue:
|
|
255
|
+
result.issues.append(issue)
|
|
256
|
+
|
|
257
|
+
LOGGER.info(
|
|
258
|
+
f"Karma: {result.passed} passed, {result.failed} failed, "
|
|
259
|
+
f"{result.skipped} skipped, {result.errors} errors"
|
|
260
|
+
)
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
def _parse_stdout(
|
|
264
|
+
self,
|
|
265
|
+
stdout: str,
|
|
266
|
+
stderr: str,
|
|
267
|
+
project_root: Path,
|
|
268
|
+
) -> TestResult:
|
|
269
|
+
"""Parse Karma output from stdout/stderr.
|
|
270
|
+
|
|
271
|
+
This is a fallback when JSON reporter is not available.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
stdout: Standard output from Karma.
|
|
275
|
+
stderr: Standard error from Karma.
|
|
276
|
+
project_root: Project root directory.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
TestResult with parsed data.
|
|
280
|
+
"""
|
|
281
|
+
result = TestResult()
|
|
282
|
+
|
|
283
|
+
# Look for summary line like "Executed 42 of 42 SUCCESS"
|
|
284
|
+
# or "Executed 42 of 42 (1 FAILED)"
|
|
285
|
+
import re
|
|
286
|
+
|
|
287
|
+
# Pattern for success: "Executed X of Y SUCCESS"
|
|
288
|
+
success_pattern = r"Executed (\d+) of (\d+).*SUCCESS"
|
|
289
|
+
# Pattern for failures: "Executed X of Y (Z FAILED)"
|
|
290
|
+
failure_pattern = r"Executed (\d+) of (\d+).*\((\d+) FAILED\)"
|
|
291
|
+
# Pattern for skipped: "Executed X of Y (Z skipped)"
|
|
292
|
+
skipped_pattern = r"\((\d+) skipped\)"
|
|
293
|
+
|
|
294
|
+
output = stdout + stderr
|
|
295
|
+
|
|
296
|
+
success_match = re.search(success_pattern, output)
|
|
297
|
+
failure_match = re.search(failure_pattern, output)
|
|
298
|
+
skipped_match = re.search(skipped_pattern, output)
|
|
299
|
+
|
|
300
|
+
if failure_match:
|
|
301
|
+
executed = int(failure_match.group(1))
|
|
302
|
+
failed = int(failure_match.group(3))
|
|
303
|
+
result.failed = failed
|
|
304
|
+
result.passed = executed - failed
|
|
305
|
+
elif success_match:
|
|
306
|
+
executed = int(success_match.group(1))
|
|
307
|
+
result.passed = executed
|
|
308
|
+
|
|
309
|
+
if skipped_match:
|
|
310
|
+
result.skipped = int(skipped_match.group(1))
|
|
311
|
+
|
|
312
|
+
# Parse individual failure messages
|
|
313
|
+
# Pattern: "FAILED: Suite Name Test Name"
|
|
314
|
+
failure_lines = re.findall(r"FAILED[:\s]+(.+)", output)
|
|
315
|
+
for failure in failure_lines:
|
|
316
|
+
issue = self._failure_line_to_issue(failure, project_root)
|
|
317
|
+
if issue:
|
|
318
|
+
result.issues.append(issue)
|
|
319
|
+
|
|
320
|
+
LOGGER.info(
|
|
321
|
+
f"Karma: {result.passed} passed, {result.failed} failed, "
|
|
322
|
+
f"{result.skipped} skipped"
|
|
323
|
+
)
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
def _test_to_issue(
|
|
327
|
+
self,
|
|
328
|
+
test_result: Dict[str, Any],
|
|
329
|
+
project_root: Path,
|
|
330
|
+
) -> Optional[UnifiedIssue]:
|
|
331
|
+
"""Convert Karma test failure to UnifiedIssue.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
test_result: Test result dict from Karma JSON.
|
|
335
|
+
project_root: Project root directory.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
UnifiedIssue or None.
|
|
339
|
+
"""
|
|
340
|
+
try:
|
|
341
|
+
suite = test_result.get("suite", [])
|
|
342
|
+
description_name = test_result.get("description", "")
|
|
343
|
+
log = test_result.get("log", [])
|
|
344
|
+
|
|
345
|
+
# Build full test name
|
|
346
|
+
full_name = " > ".join(suite + [description_name])
|
|
347
|
+
|
|
348
|
+
# Get failure message
|
|
349
|
+
message = log[0] if log else "Test failed"
|
|
350
|
+
|
|
351
|
+
# Try to extract file path and line from error stack
|
|
352
|
+
file_path, line_number = self._extract_location(message, project_root)
|
|
353
|
+
|
|
354
|
+
# Generate deterministic ID
|
|
355
|
+
issue_id = self._generate_issue_id(full_name, message)
|
|
356
|
+
|
|
357
|
+
return UnifiedIssue(
|
|
358
|
+
id=issue_id,
|
|
359
|
+
domain=ToolDomain.TESTING,
|
|
360
|
+
source_tool="karma",
|
|
361
|
+
severity=Severity.HIGH,
|
|
362
|
+
rule_id="failed",
|
|
363
|
+
title=f"{full_name}: {self._truncate(message, 80)}",
|
|
364
|
+
description=message,
|
|
365
|
+
file_path=file_path,
|
|
366
|
+
line_start=line_number,
|
|
367
|
+
line_end=line_number,
|
|
368
|
+
fixable=False,
|
|
369
|
+
metadata={
|
|
370
|
+
"full_name": full_name,
|
|
371
|
+
"suite": suite,
|
|
372
|
+
"description": description_name,
|
|
373
|
+
"log": log,
|
|
374
|
+
},
|
|
375
|
+
)
|
|
376
|
+
except Exception as e:
|
|
377
|
+
LOGGER.warning(f"Failed to parse Karma test failure: {e}")
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
def _failure_line_to_issue(
|
|
381
|
+
self,
|
|
382
|
+
failure: str,
|
|
383
|
+
project_root: Path,
|
|
384
|
+
) -> Optional[UnifiedIssue]:
|
|
385
|
+
"""Convert a failure line to UnifiedIssue.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
failure: Failure description string.
|
|
389
|
+
project_root: Project root directory.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
UnifiedIssue or None.
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
issue_id = self._generate_issue_id(failure, "")
|
|
396
|
+
|
|
397
|
+
return UnifiedIssue(
|
|
398
|
+
id=issue_id,
|
|
399
|
+
domain=ToolDomain.TESTING,
|
|
400
|
+
source_tool="karma",
|
|
401
|
+
severity=Severity.HIGH,
|
|
402
|
+
rule_id="failed",
|
|
403
|
+
title=self._truncate(failure, 100),
|
|
404
|
+
description=failure,
|
|
405
|
+
file_path=None,
|
|
406
|
+
line_start=None,
|
|
407
|
+
line_end=None,
|
|
408
|
+
fixable=False,
|
|
409
|
+
metadata={
|
|
410
|
+
"failure_line": failure,
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
except Exception as e:
|
|
414
|
+
LOGGER.warning(f"Failed to parse Karma failure line: {e}")
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
def _extract_location(
|
|
418
|
+
self,
|
|
419
|
+
message: str,
|
|
420
|
+
project_root: Path,
|
|
421
|
+
) -> tuple[Optional[Path], Optional[int]]:
|
|
422
|
+
"""Extract file path and line number from error message.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
message: Error message with potential stack trace.
|
|
426
|
+
project_root: Project root directory.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
Tuple of (file_path, line_number) or (None, None).
|
|
430
|
+
"""
|
|
431
|
+
import re
|
|
432
|
+
|
|
433
|
+
# Look for patterns like "at Context.<anonymous> (src/app/foo.spec.ts:42:15)"
|
|
434
|
+
# or "src/app/foo.spec.ts:42:15"
|
|
435
|
+
patterns = [
|
|
436
|
+
r"\(([^)]+\.(?:spec|test)\.ts):(\d+):\d+\)",
|
|
437
|
+
r"([^\s]+\.(?:spec|test)\.ts):(\d+):\d+",
|
|
438
|
+
r"\(([^)]+\.ts):(\d+):\d+\)",
|
|
439
|
+
r"([^\s]+\.ts):(\d+):\d+",
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
for pattern in patterns:
|
|
443
|
+
match = re.search(pattern, message)
|
|
444
|
+
if match:
|
|
445
|
+
file_str = match.group(1)
|
|
446
|
+
line_num = int(match.group(2))
|
|
447
|
+
file_path = Path(file_str)
|
|
448
|
+
if not file_path.is_absolute():
|
|
449
|
+
file_path = project_root / file_path
|
|
450
|
+
return file_path, line_num
|
|
451
|
+
|
|
452
|
+
return None, None
|
|
453
|
+
|
|
454
|
+
def _truncate(self, text: str, max_length: int) -> str:
|
|
455
|
+
"""Truncate text to max length.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
text: Text to truncate.
|
|
459
|
+
max_length: Maximum length.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
Truncated text.
|
|
463
|
+
"""
|
|
464
|
+
text = text.replace("\n", " ").strip()
|
|
465
|
+
if len(text) <= max_length:
|
|
466
|
+
return text
|
|
467
|
+
return text[:max_length - 3] + "..."
|
|
468
|
+
|
|
469
|
+
def _generate_issue_id(self, full_name: str, message: str) -> str:
|
|
470
|
+
"""Generate deterministic issue ID.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
full_name: Full test name.
|
|
474
|
+
message: Failure message.
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
Unique issue ID.
|
|
478
|
+
"""
|
|
479
|
+
content = f"{full_name}:{message[:100]}"
|
|
480
|
+
hash_val = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
481
|
+
return f"karma-{hash_val}"
|