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.
Files changed (91) hide show
  1. lucidscan/__init__.py +12 -0
  2. lucidscan/bootstrap/__init__.py +26 -0
  3. lucidscan/bootstrap/paths.py +160 -0
  4. lucidscan/bootstrap/platform.py +111 -0
  5. lucidscan/bootstrap/validation.py +76 -0
  6. lucidscan/bootstrap/versions.py +119 -0
  7. lucidscan/cli/__init__.py +50 -0
  8. lucidscan/cli/__main__.py +8 -0
  9. lucidscan/cli/arguments.py +405 -0
  10. lucidscan/cli/commands/__init__.py +64 -0
  11. lucidscan/cli/commands/autoconfigure.py +294 -0
  12. lucidscan/cli/commands/help.py +69 -0
  13. lucidscan/cli/commands/init.py +656 -0
  14. lucidscan/cli/commands/list_scanners.py +59 -0
  15. lucidscan/cli/commands/scan.py +307 -0
  16. lucidscan/cli/commands/serve.py +142 -0
  17. lucidscan/cli/commands/status.py +84 -0
  18. lucidscan/cli/commands/validate.py +105 -0
  19. lucidscan/cli/config_bridge.py +152 -0
  20. lucidscan/cli/exit_codes.py +17 -0
  21. lucidscan/cli/runner.py +284 -0
  22. lucidscan/config/__init__.py +29 -0
  23. lucidscan/config/ignore.py +178 -0
  24. lucidscan/config/loader.py +431 -0
  25. lucidscan/config/models.py +316 -0
  26. lucidscan/config/validation.py +645 -0
  27. lucidscan/core/__init__.py +3 -0
  28. lucidscan/core/domain_runner.py +463 -0
  29. lucidscan/core/git.py +174 -0
  30. lucidscan/core/logging.py +34 -0
  31. lucidscan/core/models.py +207 -0
  32. lucidscan/core/streaming.py +340 -0
  33. lucidscan/core/subprocess_runner.py +164 -0
  34. lucidscan/detection/__init__.py +21 -0
  35. lucidscan/detection/detector.py +154 -0
  36. lucidscan/detection/frameworks.py +270 -0
  37. lucidscan/detection/languages.py +328 -0
  38. lucidscan/detection/tools.py +229 -0
  39. lucidscan/generation/__init__.py +15 -0
  40. lucidscan/generation/config_generator.py +275 -0
  41. lucidscan/generation/package_installer.py +330 -0
  42. lucidscan/mcp/__init__.py +20 -0
  43. lucidscan/mcp/formatter.py +510 -0
  44. lucidscan/mcp/server.py +297 -0
  45. lucidscan/mcp/tools.py +1049 -0
  46. lucidscan/mcp/watcher.py +237 -0
  47. lucidscan/pipeline/__init__.py +17 -0
  48. lucidscan/pipeline/executor.py +187 -0
  49. lucidscan/pipeline/parallel.py +181 -0
  50. lucidscan/plugins/__init__.py +40 -0
  51. lucidscan/plugins/coverage/__init__.py +28 -0
  52. lucidscan/plugins/coverage/base.py +160 -0
  53. lucidscan/plugins/coverage/coverage_py.py +454 -0
  54. lucidscan/plugins/coverage/istanbul.py +411 -0
  55. lucidscan/plugins/discovery.py +107 -0
  56. lucidscan/plugins/enrichers/__init__.py +61 -0
  57. lucidscan/plugins/enrichers/base.py +63 -0
  58. lucidscan/plugins/linters/__init__.py +26 -0
  59. lucidscan/plugins/linters/base.py +125 -0
  60. lucidscan/plugins/linters/biome.py +448 -0
  61. lucidscan/plugins/linters/checkstyle.py +393 -0
  62. lucidscan/plugins/linters/eslint.py +368 -0
  63. lucidscan/plugins/linters/ruff.py +498 -0
  64. lucidscan/plugins/reporters/__init__.py +45 -0
  65. lucidscan/plugins/reporters/base.py +30 -0
  66. lucidscan/plugins/reporters/json_reporter.py +79 -0
  67. lucidscan/plugins/reporters/sarif_reporter.py +303 -0
  68. lucidscan/plugins/reporters/summary_reporter.py +61 -0
  69. lucidscan/plugins/reporters/table_reporter.py +81 -0
  70. lucidscan/plugins/scanners/__init__.py +57 -0
  71. lucidscan/plugins/scanners/base.py +60 -0
  72. lucidscan/plugins/scanners/checkov.py +484 -0
  73. lucidscan/plugins/scanners/opengrep.py +464 -0
  74. lucidscan/plugins/scanners/trivy.py +492 -0
  75. lucidscan/plugins/test_runners/__init__.py +27 -0
  76. lucidscan/plugins/test_runners/base.py +111 -0
  77. lucidscan/plugins/test_runners/jest.py +381 -0
  78. lucidscan/plugins/test_runners/karma.py +481 -0
  79. lucidscan/plugins/test_runners/playwright.py +434 -0
  80. lucidscan/plugins/test_runners/pytest.py +598 -0
  81. lucidscan/plugins/type_checkers/__init__.py +27 -0
  82. lucidscan/plugins/type_checkers/base.py +106 -0
  83. lucidscan/plugins/type_checkers/mypy.py +355 -0
  84. lucidscan/plugins/type_checkers/pyright.py +313 -0
  85. lucidscan/plugins/type_checkers/typescript.py +280 -0
  86. lucidscan-0.5.12.dist-info/METADATA +242 -0
  87. lucidscan-0.5.12.dist-info/RECORD +91 -0
  88. lucidscan-0.5.12.dist-info/WHEEL +5 -0
  89. lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
  90. lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
  91. lucidscan-0.5.12.dist-info/top_level.txt +1 -0
@@ -0,0 +1,434 @@
1
+ """Playwright test runner plugin.
2
+
3
+ Playwright is a framework for end-to-end testing of web applications.
4
+ https://playwright.dev/
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import shutil
12
+ import subprocess
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from lucidscan.core.logging import get_logger
17
+ from lucidscan.core.models import (
18
+ ScanContext,
19
+ Severity,
20
+ ToolDomain,
21
+ UnifiedIssue,
22
+ )
23
+ from lucidscan.plugins.test_runners.base import TestRunnerPlugin, TestResult
24
+
25
+ LOGGER = get_logger(__name__)
26
+
27
+
28
+ class PlaywrightRunner(TestRunnerPlugin):
29
+ """Playwright test runner plugin for E2E test execution."""
30
+
31
+ def __init__(self, project_root: Optional[Path] = None):
32
+ """Initialize PlaywrightRunner.
33
+
34
+ Args:
35
+ project_root: Optional project root for finding Playwright installation.
36
+ """
37
+ self._project_root = project_root
38
+
39
+ @property
40
+ def name(self) -> str:
41
+ """Plugin identifier."""
42
+ return "playwright"
43
+
44
+ @property
45
+ def languages(self) -> List[str]:
46
+ """Supported languages."""
47
+ return ["javascript", "typescript"]
48
+
49
+ def get_version(self) -> str:
50
+ """Get Playwright version.
51
+
52
+ Returns:
53
+ Version string or 'unknown' if unable to determine.
54
+ """
55
+ try:
56
+ binary = self.ensure_binary()
57
+ result = subprocess.run(
58
+ [str(binary), "--version"],
59
+ capture_output=True,
60
+ text=True,
61
+ encoding="utf-8",
62
+ errors="replace",
63
+ timeout=30,
64
+ )
65
+ if result.returncode == 0:
66
+ # Output is like "Version 1.55.0"
67
+ version = result.stdout.strip()
68
+ if version.startswith("Version "):
69
+ return version[8:]
70
+ return version
71
+ except Exception:
72
+ pass
73
+ return "unknown"
74
+
75
+ def ensure_binary(self) -> Path:
76
+ """Ensure Playwright is available.
77
+
78
+ Checks for Playwright in:
79
+ 1. Project's node_modules/.bin/playwright
80
+ 2. System PATH (globally installed)
81
+
82
+ Returns:
83
+ Path to Playwright binary.
84
+
85
+ Raises:
86
+ FileNotFoundError: If Playwright is not installed.
87
+ """
88
+ # Check project node_modules first
89
+ if self._project_root:
90
+ node_playwright = self._project_root / "node_modules" / ".bin" / "playwright"
91
+ if node_playwright.exists():
92
+ return node_playwright
93
+
94
+ # Check system PATH
95
+ playwright_path = shutil.which("playwright")
96
+ if playwright_path:
97
+ return Path(playwright_path)
98
+
99
+ raise FileNotFoundError(
100
+ "Playwright is not installed. Install it with:\n"
101
+ " npm install @playwright/test --save-dev\n"
102
+ " npx playwright install"
103
+ )
104
+
105
+ def run_tests(self, context: ScanContext) -> TestResult:
106
+ """Run Playwright on the specified paths.
107
+
108
+ Args:
109
+ context: Scan context with paths and configuration.
110
+
111
+ Returns:
112
+ TestResult with test statistics and issues for failures.
113
+ """
114
+ try:
115
+ binary = self.ensure_binary()
116
+ except FileNotFoundError as e:
117
+ LOGGER.warning(str(e))
118
+ return TestResult()
119
+
120
+ cmd = [
121
+ str(binary),
122
+ "test",
123
+ "--reporter=json",
124
+ ]
125
+
126
+ # Add paths to test if specified
127
+ if context.paths:
128
+ paths = [str(p) for p in context.paths]
129
+ cmd.extend(paths)
130
+
131
+ LOGGER.debug(f"Running: {' '.join(cmd)}")
132
+
133
+ try:
134
+ result = subprocess.run(
135
+ cmd,
136
+ capture_output=True,
137
+ text=True,
138
+ encoding="utf-8",
139
+ errors="replace",
140
+ cwd=str(context.project_root),
141
+ timeout=900, # 15 minute timeout for E2E tests
142
+ )
143
+ except subprocess.TimeoutExpired:
144
+ LOGGER.warning("Playwright timed out after 900 seconds")
145
+ return TestResult()
146
+ except Exception as e:
147
+ LOGGER.error(f"Failed to run Playwright: {e}")
148
+ return TestResult()
149
+
150
+ # Playwright outputs JSON to stdout when using --reporter=json
151
+ return self._parse_json_output(result.stdout, context.project_root)
152
+
153
+ def _parse_json_output(
154
+ self,
155
+ output: str,
156
+ project_root: Path,
157
+ ) -> TestResult:
158
+ """Parse Playwright JSON output.
159
+
160
+ Args:
161
+ output: JSON output from Playwright.
162
+ project_root: Project root directory.
163
+
164
+ Returns:
165
+ TestResult with parsed data.
166
+ """
167
+ if not output.strip():
168
+ return TestResult()
169
+
170
+ try:
171
+ report = json.loads(output)
172
+ except json.JSONDecodeError as e:
173
+ LOGGER.warning(f"Failed to parse Playwright JSON output: {e}")
174
+ return TestResult()
175
+
176
+ return self._process_report(report, project_root)
177
+
178
+ def _process_report(
179
+ self,
180
+ report: Dict[str, Any],
181
+ project_root: Path,
182
+ ) -> TestResult:
183
+ """Process Playwright JSON report.
184
+
185
+ Args:
186
+ report: Parsed JSON report.
187
+ project_root: Project root directory.
188
+
189
+ Returns:
190
+ TestResult with processed data.
191
+ """
192
+ stats = report.get("stats", {})
193
+
194
+ # Calculate statistics
195
+ num_passed = stats.get("expected", 0)
196
+ num_failed = stats.get("unexpected", 0)
197
+ num_skipped = stats.get("skipped", 0)
198
+ num_flaky = stats.get("flaky", 0)
199
+
200
+ # Get duration
201
+ duration_ms = stats.get("duration", 0)
202
+
203
+ result = TestResult(
204
+ passed=num_passed + num_flaky, # Flaky tests eventually passed
205
+ failed=num_failed,
206
+ skipped=num_skipped,
207
+ errors=0,
208
+ duration_ms=duration_ms,
209
+ )
210
+
211
+ # Process suites and tests
212
+ suites = report.get("suites", [])
213
+ for suite in suites:
214
+ self._process_suite(suite, [], project_root, result)
215
+
216
+ LOGGER.info(
217
+ f"Playwright: {result.passed} passed, {result.failed} failed, "
218
+ f"{result.skipped} skipped"
219
+ )
220
+ return result
221
+
222
+ def _process_suite(
223
+ self,
224
+ suite: Dict[str, Any],
225
+ ancestors: List[str],
226
+ project_root: Path,
227
+ result: TestResult,
228
+ ) -> None:
229
+ """Process a test suite recursively.
230
+
231
+ Args:
232
+ suite: Suite data from Playwright report.
233
+ ancestors: List of parent suite titles.
234
+ project_root: Project root directory.
235
+ result: TestResult to append issues to.
236
+ """
237
+ suite_title = suite.get("title", "")
238
+ current_ancestors = ancestors + [suite_title] if suite_title else ancestors
239
+
240
+ # Process specs (test cases)
241
+ for spec in suite.get("specs", []):
242
+ self._process_spec(spec, current_ancestors, suite, project_root, result)
243
+
244
+ # Process nested suites
245
+ for nested_suite in suite.get("suites", []):
246
+ self._process_suite(nested_suite, current_ancestors, project_root, result)
247
+
248
+ def _process_spec(
249
+ self,
250
+ spec: Dict[str, Any],
251
+ ancestors: List[str],
252
+ suite: Dict[str, Any],
253
+ project_root: Path,
254
+ result: TestResult,
255
+ ) -> None:
256
+ """Process a test spec.
257
+
258
+ Args:
259
+ spec: Spec data from Playwright report.
260
+ ancestors: List of parent suite titles.
261
+ suite: Parent suite data.
262
+ project_root: Project root directory.
263
+ result: TestResult to append issues to.
264
+ """
265
+ # Check if any test failed
266
+ for test in spec.get("tests", []):
267
+ status = test.get("status", "")
268
+
269
+ if status in ["unexpected", "failed"]:
270
+ issue = self._test_to_issue(
271
+ spec, test, ancestors, suite, project_root
272
+ )
273
+ if issue:
274
+ result.issues.append(issue)
275
+
276
+ def _test_to_issue(
277
+ self,
278
+ spec: Dict[str, Any],
279
+ test: Dict[str, Any],
280
+ ancestors: List[str],
281
+ suite: Dict[str, Any],
282
+ project_root: Path,
283
+ ) -> Optional[UnifiedIssue]:
284
+ """Convert Playwright test failure to UnifiedIssue.
285
+
286
+ Args:
287
+ spec: Spec data.
288
+ test: Test data.
289
+ ancestors: List of parent suite titles.
290
+ suite: Parent suite data.
291
+ project_root: Project root directory.
292
+
293
+ Returns:
294
+ UnifiedIssue or None.
295
+ """
296
+ try:
297
+ test_title = spec.get("title", "")
298
+ full_name = " > ".join(ancestors + [test_title])
299
+
300
+ # Get error information from results
301
+ results = test.get("results", [])
302
+ error_message = ""
303
+ error_stack = ""
304
+
305
+ for result_item in results:
306
+ if result_item.get("status") in ["unexpected", "failed"]:
307
+ error = result_item.get("error", {})
308
+ error_message = error.get("message", "")
309
+ error_stack = error.get("stack", "")
310
+ break
311
+
312
+ # Get file location
313
+ file_path = None
314
+ line_number = None
315
+
316
+ # Try to get location from spec
317
+ spec_file = spec.get("file", "") or suite.get("file", "")
318
+ spec_line = spec.get("line")
319
+
320
+ if spec_file:
321
+ file_path = Path(spec_file)
322
+ if not file_path.is_absolute():
323
+ file_path = project_root / file_path
324
+ line_number = spec_line
325
+
326
+ # If no spec location, try to extract from stack trace
327
+ if not file_path and error_stack:
328
+ file_path, line_number = self._extract_location(
329
+ error_stack, project_root
330
+ )
331
+
332
+ # Build description
333
+ description = error_message
334
+ if error_stack and error_stack != error_message:
335
+ description = f"{error_message}\n\n{error_stack}"
336
+
337
+ # Generate deterministic ID
338
+ issue_id = self._generate_issue_id(full_name, error_message)
339
+
340
+ # Get browser info
341
+ project_name = test.get("projectName", "")
342
+ browser_info = f" [{project_name}]" if project_name else ""
343
+
344
+ return UnifiedIssue(
345
+ id=issue_id,
346
+ domain=ToolDomain.TESTING,
347
+ source_tool="playwright",
348
+ severity=Severity.HIGH,
349
+ rule_id="failed",
350
+ title=f"{full_name}{browser_info}: {self._truncate(error_message, 60)}",
351
+ description=description,
352
+ file_path=file_path,
353
+ line_start=line_number,
354
+ line_end=line_number,
355
+ fixable=False,
356
+ metadata={
357
+ "full_name": full_name,
358
+ "test_title": test_title,
359
+ "ancestors": ancestors,
360
+ "project_name": project_name,
361
+ "error_message": error_message,
362
+ "error_stack": error_stack,
363
+ },
364
+ )
365
+ except Exception as e:
366
+ LOGGER.warning(f"Failed to parse Playwright test failure: {e}")
367
+ return None
368
+
369
+ def _extract_location(
370
+ self,
371
+ stack: str,
372
+ project_root: Path,
373
+ ) -> tuple[Optional[Path], Optional[int]]:
374
+ """Extract file path and line number from stack trace.
375
+
376
+ Args:
377
+ stack: Error stack trace.
378
+ project_root: Project root directory.
379
+
380
+ Returns:
381
+ Tuple of (file_path, line_number) or (None, None).
382
+ """
383
+ import re
384
+
385
+ # Look for patterns like "at /path/to/file.ts:42:15"
386
+ # or "file.spec.ts:42"
387
+ patterns = [
388
+ r"at\s+(?:[^\s]+\s+\()?([^:]+\.(?:spec|test)\.[tj]sx?):(\d+)",
389
+ r"([^\s:]+\.(?:spec|test)\.[tj]sx?):(\d+)",
390
+ r"at\s+(?:[^\s]+\s+\()?([^:]+\.[tj]sx?):(\d+)",
391
+ ]
392
+
393
+ for pattern in patterns:
394
+ match = re.search(pattern, stack)
395
+ if match:
396
+ file_str = match.group(1)
397
+ line_num = int(match.group(2))
398
+ file_path = Path(file_str)
399
+ if not file_path.is_absolute():
400
+ file_path = project_root / file_path
401
+ return file_path, line_num
402
+
403
+ return None, None
404
+
405
+ def _truncate(self, text: str, max_length: int) -> str:
406
+ """Truncate text to max length.
407
+
408
+ Args:
409
+ text: Text to truncate.
410
+ max_length: Maximum length.
411
+
412
+ Returns:
413
+ Truncated text.
414
+ """
415
+ if not text:
416
+ return "Test failed"
417
+ text = text.replace("\n", " ").strip()
418
+ if len(text) <= max_length:
419
+ return text
420
+ return text[:max_length - 3] + "..."
421
+
422
+ def _generate_issue_id(self, full_name: str, message: str) -> str:
423
+ """Generate deterministic issue ID.
424
+
425
+ Args:
426
+ full_name: Full test name.
427
+ message: Failure message.
428
+
429
+ Returns:
430
+ Unique issue ID.
431
+ """
432
+ content = f"{full_name}:{message[:100] if message else ''}"
433
+ hash_val = hashlib.sha256(content.encode()).hexdigest()[:12]
434
+ return f"playwright-{hash_val}"