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,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}"
|