quantikmind-cli 0.1.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.
- quantikmind_cli/__init__.py +6 -0
- quantikmind_cli/agent/__init__.py +1 -0
- quantikmind_cli/agent/prometheus.py +117 -0
- quantikmind_cli/agent/push.py +1 -0
- quantikmind_cli/agent/service.py +1 -0
- quantikmind_cli/api.py +326 -0
- quantikmind_cli/cli.py +1672 -0
- quantikmind_cli/config.py +111 -0
- quantikmind_cli/git.py +83 -0
- quantikmind_cli/manifest.py +134 -0
- quantikmind_cli/record_tests.py +268 -0
- quantikmind_cli/scanner.py +547 -0
- quantikmind_cli/subset.py +106 -0
- quantikmind_cli-0.1.0.dist-info/METADATA +695 -0
- quantikmind_cli-0.1.0.dist-info/RECORD +18 -0
- quantikmind_cli-0.1.0.dist-info/WHEEL +5 -0
- quantikmind_cli-0.1.0.dist-info/entry_points.txt +2 -0
- quantikmind_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SYNC_LIBRARY_FRAMEWORKS = ("pytest", "jest", "maven", "gradle", "cypress", "playwright", "robot")
|
|
13
|
+
PYTEST_FILE_RE = re.compile(r"(^test_.*\.py$|.*_test\.py$)")
|
|
14
|
+
JEST_FILE_RE = re.compile(r".*(\.test|\.spec)\.(js|jsx|ts|tsx)$")
|
|
15
|
+
JAVA_TEST_FILE_RE = re.compile(r".*Test\.java$")
|
|
16
|
+
CYPRESS_FILE_RE = re.compile(r".*\.cy\.(js|ts)$")
|
|
17
|
+
PLAYWRIGHT_FILE_RE = re.compile(r".*(\.test|\.spec)\.(js|ts)$")
|
|
18
|
+
JEST_BLOCK_RE = re.compile(
|
|
19
|
+
r"\b(?P<kind>describe|it|test)(?:\.(?:only|skip|todo|each))?\s*\(\s*(?P<quote>['\"`])(?P<name>.*?)(?P=quote)",
|
|
20
|
+
re.DOTALL,
|
|
21
|
+
)
|
|
22
|
+
CYPRESS_BLOCK_RE = re.compile(
|
|
23
|
+
r"\b(?P<kind>describe|context|it|specify)(?:\.(?:only|skip))?\s*\(\s*"
|
|
24
|
+
r"(?P<quote>['\"`])(?P<name>.*?)(?P=quote)",
|
|
25
|
+
re.DOTALL,
|
|
26
|
+
)
|
|
27
|
+
PLAYWRIGHT_BLOCK_RE = re.compile(
|
|
28
|
+
r"\b(?P<kind>test(?:\.describe)?)(?:\.(?:only|skip|fixme))?\s*\(\s*"
|
|
29
|
+
r"(?P<quote>['\"`])(?P<name>.*?)(?P=quote)",
|
|
30
|
+
re.DOTALL,
|
|
31
|
+
)
|
|
32
|
+
JAVA_TEST_METHOD_RE = re.compile(
|
|
33
|
+
r"@Test(?:\s*\([^)]*\))?\s*(?:\r?\n\s*(?:@\w+(?:\([^)]*\))?\s*)*)*"
|
|
34
|
+
r"(?:public|private|protected)?\s*(?:static\s+)?(?:void|[\w<>\[\], ?]+)\s+(?P<name>\w+)\s*\(",
|
|
35
|
+
re.MULTILINE,
|
|
36
|
+
)
|
|
37
|
+
COMMON_PATH_PARTS = {
|
|
38
|
+
"__tests__",
|
|
39
|
+
"com",
|
|
40
|
+
"io",
|
|
41
|
+
"java",
|
|
42
|
+
"javascript",
|
|
43
|
+
"js",
|
|
44
|
+
"jvm",
|
|
45
|
+
"net",
|
|
46
|
+
"org",
|
|
47
|
+
"py",
|
|
48
|
+
"python",
|
|
49
|
+
"spec",
|
|
50
|
+
"specs",
|
|
51
|
+
"src",
|
|
52
|
+
"test",
|
|
53
|
+
"tests",
|
|
54
|
+
"ts",
|
|
55
|
+
"typescript",
|
|
56
|
+
"unit",
|
|
57
|
+
"integration",
|
|
58
|
+
}
|
|
59
|
+
HIGH_CRITICALITY_HINTS = ("checkout", "payment", "billing", "invoice", "auth", "login", "security")
|
|
60
|
+
CRITICAL_HINTS = ("critical", "p0", "sev0", "prod_down", "outage")
|
|
61
|
+
LOW_CRITICALITY_HINTS = ("smoke", "snapshot", "lint", "format", "style")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LibraryScanError(RuntimeError):
|
|
65
|
+
"""Raised when a functional test library cannot be generated."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class DiscoveredTest:
|
|
70
|
+
test_id: str
|
|
71
|
+
name: str
|
|
72
|
+
framework: str
|
|
73
|
+
path: Path
|
|
74
|
+
service: str
|
|
75
|
+
selector: str | None = None
|
|
76
|
+
suite: str = "default"
|
|
77
|
+
business_criticality: str | None = None
|
|
78
|
+
|
|
79
|
+
def to_payload(self, root: Path) -> dict[str, Any]:
|
|
80
|
+
relative_path = _relative_posix(self.path, root)
|
|
81
|
+
payload: dict[str, Any] = {
|
|
82
|
+
"test_id": self.test_id,
|
|
83
|
+
"name": self.name,
|
|
84
|
+
"service": self.service,
|
|
85
|
+
"suite": self.suite,
|
|
86
|
+
"framework": self.framework,
|
|
87
|
+
"repo_ref": {"path": relative_path},
|
|
88
|
+
"code_mapping": {"file_globs": [relative_path]},
|
|
89
|
+
}
|
|
90
|
+
if self.selector:
|
|
91
|
+
payload["repo_ref"]["selector"] = self.selector
|
|
92
|
+
if self.business_criticality:
|
|
93
|
+
payload["business_criticality"] = self.business_criticality
|
|
94
|
+
return payload
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class LibraryScanResult:
|
|
99
|
+
tests: list[DiscoveredTest] = field(default_factory=list)
|
|
100
|
+
warnings: list[str] = field(default_factory=list)
|
|
101
|
+
|
|
102
|
+
def payload(self, root: str | Path) -> list[dict[str, Any]]:
|
|
103
|
+
root_path = Path(root)
|
|
104
|
+
return [test.to_payload(root_path) for test in self.tests]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def scan_functional_library(
|
|
108
|
+
framework: str,
|
|
109
|
+
test_dir: str | Path,
|
|
110
|
+
*,
|
|
111
|
+
root: str | Path = ".",
|
|
112
|
+
) -> LibraryScanResult:
|
|
113
|
+
if framework not in SYNC_LIBRARY_FRAMEWORKS:
|
|
114
|
+
raise LibraryScanError(f"Unsupported framework for qmind sync library: {framework}")
|
|
115
|
+
|
|
116
|
+
root_path = Path(root).resolve()
|
|
117
|
+
test_root = (root_path / test_dir).resolve()
|
|
118
|
+
if not test_root.exists():
|
|
119
|
+
raise LibraryScanError(f"Test directory does not exist: {test_dir}")
|
|
120
|
+
if not test_root.is_dir():
|
|
121
|
+
raise LibraryScanError(f"Test path is not a directory: {test_dir}")
|
|
122
|
+
|
|
123
|
+
if framework == "pytest":
|
|
124
|
+
return scan_pytest(test_root, root=root_path)
|
|
125
|
+
if framework == "jest":
|
|
126
|
+
return scan_jest(test_root, root=root_path)
|
|
127
|
+
if framework == "cypress":
|
|
128
|
+
return scan_cypress(test_root, root=root_path)
|
|
129
|
+
if framework == "playwright":
|
|
130
|
+
return scan_playwright(test_root, root=root_path)
|
|
131
|
+
if framework == "robot":
|
|
132
|
+
return scan_robot(test_root, root=root_path)
|
|
133
|
+
return scan_java(framework, test_root, root=root_path)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def scan_pytest(test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
|
|
137
|
+
root_path = Path(root).resolve()
|
|
138
|
+
test_root_path = Path(test_root).resolve()
|
|
139
|
+
collected = _collect_pytest(test_root_path, root=root_path)
|
|
140
|
+
if collected.tests:
|
|
141
|
+
return collected
|
|
142
|
+
|
|
143
|
+
fallback = _scan_pytest_files(test_root_path, root=root_path)
|
|
144
|
+
fallback.warnings = [*collected.warnings, *fallback.warnings]
|
|
145
|
+
return fallback
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def scan_jest(test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
|
|
149
|
+
root_path = Path(root).resolve()
|
|
150
|
+
test_root_path = Path(test_root).resolve()
|
|
151
|
+
result = LibraryScanResult()
|
|
152
|
+
|
|
153
|
+
for path in _iter_files(test_root_path, JEST_FILE_RE):
|
|
154
|
+
try:
|
|
155
|
+
content = path.read_text(encoding="utf-8")
|
|
156
|
+
except UnicodeDecodeError:
|
|
157
|
+
result.warnings.append(f"Could not parse {path}: file is not UTF-8")
|
|
158
|
+
continue
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
result.warnings.append(f"Could not read {path}: {exc}")
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
matches = list(JEST_BLOCK_RE.finditer(content))
|
|
164
|
+
describe_stack: list[str] = []
|
|
165
|
+
for match in matches:
|
|
166
|
+
kind = match.group("kind")
|
|
167
|
+
name = _clean_name(match.group("name"))
|
|
168
|
+
if not name:
|
|
169
|
+
continue
|
|
170
|
+
if kind == "describe":
|
|
171
|
+
describe_stack = [name]
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
display_name = " ".join([*describe_stack, name]).strip()
|
|
175
|
+
selector = display_name or name
|
|
176
|
+
result.tests.append(
|
|
177
|
+
_discovered_test(
|
|
178
|
+
framework="jest",
|
|
179
|
+
path=path,
|
|
180
|
+
root=root_path,
|
|
181
|
+
test_root=test_root_path,
|
|
182
|
+
name=display_name or name,
|
|
183
|
+
selector=selector,
|
|
184
|
+
test_id=f"{_relative_posix(path, root_path)}::{selector}",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def scan_cypress(test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
|
|
192
|
+
root_path = Path(root).resolve()
|
|
193
|
+
test_root_path = Path(test_root).resolve()
|
|
194
|
+
result = LibraryScanResult()
|
|
195
|
+
|
|
196
|
+
for path in _iter_cypress_files(test_root_path):
|
|
197
|
+
scanned = _scan_js_style_tests(
|
|
198
|
+
framework="cypress",
|
|
199
|
+
path=path,
|
|
200
|
+
root=root_path,
|
|
201
|
+
test_root=test_root_path,
|
|
202
|
+
pattern=CYPRESS_BLOCK_RE,
|
|
203
|
+
suite_kinds={"describe", "context"},
|
|
204
|
+
)
|
|
205
|
+
result.tests.extend(scanned.tests)
|
|
206
|
+
result.warnings.extend(scanned.warnings)
|
|
207
|
+
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def scan_playwright(test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
|
|
212
|
+
root_path = Path(root).resolve()
|
|
213
|
+
test_root_path = Path(test_root).resolve()
|
|
214
|
+
result = LibraryScanResult()
|
|
215
|
+
|
|
216
|
+
for path in _iter_files(test_root_path, PLAYWRIGHT_FILE_RE):
|
|
217
|
+
scanned = _scan_js_style_tests(
|
|
218
|
+
framework="playwright",
|
|
219
|
+
path=path,
|
|
220
|
+
root=root_path,
|
|
221
|
+
test_root=test_root_path,
|
|
222
|
+
pattern=PLAYWRIGHT_BLOCK_RE,
|
|
223
|
+
suite_kinds={"test.describe"},
|
|
224
|
+
)
|
|
225
|
+
result.tests.extend(scanned.tests)
|
|
226
|
+
result.warnings.extend(scanned.warnings)
|
|
227
|
+
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def scan_robot(test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
|
|
232
|
+
root_path = Path(root).resolve()
|
|
233
|
+
test_root_path = Path(test_root).resolve()
|
|
234
|
+
result = LibraryScanResult()
|
|
235
|
+
|
|
236
|
+
for path in sorted(test_root_path.rglob("*.robot")):
|
|
237
|
+
try:
|
|
238
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
239
|
+
except UnicodeDecodeError:
|
|
240
|
+
result.warnings.append(f"Could not parse {path}: file is not UTF-8")
|
|
241
|
+
continue
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
result.warnings.append(f"Could not read {path}: {exc}")
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
in_test_cases = False
|
|
247
|
+
for line in lines:
|
|
248
|
+
stripped = line.strip()
|
|
249
|
+
if not stripped or stripped.startswith("#"):
|
|
250
|
+
continue
|
|
251
|
+
if stripped.startswith("***") and stripped.endswith("***"):
|
|
252
|
+
section = stripped.strip("*").strip().lower()
|
|
253
|
+
in_test_cases = section == "test cases"
|
|
254
|
+
continue
|
|
255
|
+
if not in_test_cases or line[:1].isspace():
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
name = _clean_name(stripped)
|
|
259
|
+
selector = _relative_posix(path, root_path)
|
|
260
|
+
result.tests.append(
|
|
261
|
+
_discovered_test(
|
|
262
|
+
framework="robot",
|
|
263
|
+
path=path,
|
|
264
|
+
root=root_path,
|
|
265
|
+
test_root=test_root_path,
|
|
266
|
+
name=name,
|
|
267
|
+
selector=selector,
|
|
268
|
+
test_id=f"{selector}::{name}",
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def scan_java(framework: str, test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
|
|
276
|
+
root_path = Path(root).resolve()
|
|
277
|
+
test_root_path = Path(test_root).resolve()
|
|
278
|
+
result = LibraryScanResult()
|
|
279
|
+
|
|
280
|
+
for path in _iter_files(test_root_path, JAVA_TEST_FILE_RE):
|
|
281
|
+
try:
|
|
282
|
+
content = path.read_text(encoding="utf-8")
|
|
283
|
+
except UnicodeDecodeError:
|
|
284
|
+
result.warnings.append(f"Could not parse {path}: file is not UTF-8")
|
|
285
|
+
continue
|
|
286
|
+
except OSError as exc:
|
|
287
|
+
result.warnings.append(f"Could not read {path}: {exc}")
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
class_name = path.stem
|
|
291
|
+
for match in JAVA_TEST_METHOD_RE.finditer(content):
|
|
292
|
+
method_name = match.group("name")
|
|
293
|
+
selector = f"{class_name}.{method_name}"
|
|
294
|
+
result.tests.append(
|
|
295
|
+
_discovered_test(
|
|
296
|
+
framework=framework,
|
|
297
|
+
path=path,
|
|
298
|
+
root=root_path,
|
|
299
|
+
test_root=test_root_path,
|
|
300
|
+
name=method_name,
|
|
301
|
+
selector=selector,
|
|
302
|
+
test_id=selector,
|
|
303
|
+
suite=class_name,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _scan_js_style_tests(
|
|
311
|
+
*,
|
|
312
|
+
framework: str,
|
|
313
|
+
path: Path,
|
|
314
|
+
root: Path,
|
|
315
|
+
test_root: Path,
|
|
316
|
+
pattern: re.Pattern[str],
|
|
317
|
+
suite_kinds: set[str],
|
|
318
|
+
) -> LibraryScanResult:
|
|
319
|
+
result = LibraryScanResult()
|
|
320
|
+
try:
|
|
321
|
+
content = path.read_text(encoding="utf-8")
|
|
322
|
+
except UnicodeDecodeError:
|
|
323
|
+
result.warnings.append(f"Could not parse {path}: file is not UTF-8")
|
|
324
|
+
return result
|
|
325
|
+
except OSError as exc:
|
|
326
|
+
result.warnings.append(f"Could not read {path}: {exc}")
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
relative_path = _relative_posix(path, root)
|
|
330
|
+
suite_name: str | None = None
|
|
331
|
+
for match in pattern.finditer(content):
|
|
332
|
+
kind = match.group("kind")
|
|
333
|
+
name = _clean_name(match.group("name"))
|
|
334
|
+
if not name:
|
|
335
|
+
continue
|
|
336
|
+
if kind in suite_kinds:
|
|
337
|
+
suite_name = name
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
display_name = " ".join(part for part in (suite_name, name) if part).strip() or name
|
|
341
|
+
result.tests.append(
|
|
342
|
+
_discovered_test(
|
|
343
|
+
framework=framework,
|
|
344
|
+
path=path,
|
|
345
|
+
root=root,
|
|
346
|
+
test_root=test_root,
|
|
347
|
+
name=display_name,
|
|
348
|
+
selector=relative_path,
|
|
349
|
+
test_id=f"{relative_path}::{display_name}",
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _collect_pytest(test_root: Path, *, root: Path) -> LibraryScanResult:
|
|
357
|
+
result = LibraryScanResult()
|
|
358
|
+
try:
|
|
359
|
+
completed = subprocess.run(
|
|
360
|
+
[sys.executable, "-m", "pytest", "--collect-only", "-q", str(test_root)],
|
|
361
|
+
cwd=root,
|
|
362
|
+
capture_output=True,
|
|
363
|
+
text=True,
|
|
364
|
+
timeout=60,
|
|
365
|
+
check=False,
|
|
366
|
+
)
|
|
367
|
+
except FileNotFoundError:
|
|
368
|
+
result.warnings.append("pytest is not available; falling back to Python source scanning.")
|
|
369
|
+
return result
|
|
370
|
+
except subprocess.SubprocessError as exc:
|
|
371
|
+
result.warnings.append(f"pytest collection failed: {exc}; falling back to Python source scanning.")
|
|
372
|
+
return result
|
|
373
|
+
|
|
374
|
+
if completed.returncode != 0:
|
|
375
|
+
details = (completed.stderr or completed.stdout).strip().splitlines()
|
|
376
|
+
suffix = f": {details[-1]}" if details else ""
|
|
377
|
+
result.warnings.append(f"pytest collection failed{suffix}; falling back to Python source scanning.")
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
for line in completed.stdout.splitlines():
|
|
381
|
+
selector = line.strip()
|
|
382
|
+
if not selector or "::" not in selector or selector.startswith("<"):
|
|
383
|
+
continue
|
|
384
|
+
path_text, name_parts = selector.split("::", 1)
|
|
385
|
+
path = (root / path_text).resolve()
|
|
386
|
+
name = name_parts.split("::")[-1]
|
|
387
|
+
result.tests.append(
|
|
388
|
+
_discovered_test(
|
|
389
|
+
framework="pytest",
|
|
390
|
+
path=path,
|
|
391
|
+
root=root,
|
|
392
|
+
test_root=test_root,
|
|
393
|
+
name=name,
|
|
394
|
+
selector=selector,
|
|
395
|
+
test_id=selector,
|
|
396
|
+
suite=_suite_from_selector(name_parts),
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _scan_pytest_files(test_root: Path, *, root: Path) -> LibraryScanResult:
|
|
404
|
+
result = LibraryScanResult()
|
|
405
|
+
for path in _iter_files(test_root, PYTEST_FILE_RE):
|
|
406
|
+
try:
|
|
407
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
408
|
+
except SyntaxError as exc:
|
|
409
|
+
result.warnings.append(f"Could not parse {path}: {exc.msg}")
|
|
410
|
+
continue
|
|
411
|
+
except UnicodeDecodeError:
|
|
412
|
+
result.warnings.append(f"Could not parse {path}: file is not UTF-8")
|
|
413
|
+
continue
|
|
414
|
+
except OSError as exc:
|
|
415
|
+
result.warnings.append(f"Could not read {path}: {exc}")
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
for node in tree.body:
|
|
419
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
|
|
420
|
+
selector = f"{_relative_posix(path, root)}::{node.name}"
|
|
421
|
+
result.tests.append(
|
|
422
|
+
_discovered_test(
|
|
423
|
+
framework="pytest",
|
|
424
|
+
path=path,
|
|
425
|
+
root=root,
|
|
426
|
+
test_root=test_root,
|
|
427
|
+
name=node.name,
|
|
428
|
+
selector=selector,
|
|
429
|
+
test_id=selector,
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
|
|
433
|
+
for child in node.body:
|
|
434
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test_"):
|
|
435
|
+
selector = f"{_relative_posix(path, root)}::{node.name}::{child.name}"
|
|
436
|
+
result.tests.append(
|
|
437
|
+
_discovered_test(
|
|
438
|
+
framework="pytest",
|
|
439
|
+
path=path,
|
|
440
|
+
root=root,
|
|
441
|
+
test_root=test_root,
|
|
442
|
+
name=child.name,
|
|
443
|
+
selector=selector,
|
|
444
|
+
test_id=selector,
|
|
445
|
+
suite=node.name,
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
return result
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _discovered_test(
|
|
453
|
+
*,
|
|
454
|
+
framework: str,
|
|
455
|
+
path: Path,
|
|
456
|
+
root: Path,
|
|
457
|
+
test_root: Path,
|
|
458
|
+
name: str,
|
|
459
|
+
selector: str,
|
|
460
|
+
test_id: str,
|
|
461
|
+
suite: str = "default",
|
|
462
|
+
) -> DiscoveredTest:
|
|
463
|
+
service = infer_service(path, root=root, test_root=test_root)
|
|
464
|
+
return DiscoveredTest(
|
|
465
|
+
test_id=test_id,
|
|
466
|
+
name=name,
|
|
467
|
+
framework=framework,
|
|
468
|
+
path=path,
|
|
469
|
+
service=service,
|
|
470
|
+
selector=selector,
|
|
471
|
+
suite=suite,
|
|
472
|
+
business_criticality=infer_business_criticality([name, selector, str(path)]),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def infer_service(path: str | Path, *, root: str | Path = ".", test_root: str | Path | None = None) -> str:
|
|
477
|
+
root_path = Path(root).resolve()
|
|
478
|
+
path_obj = Path(path).resolve()
|
|
479
|
+
try:
|
|
480
|
+
relative = path_obj.relative_to(Path(test_root).resolve() if test_root else root_path)
|
|
481
|
+
except ValueError:
|
|
482
|
+
try:
|
|
483
|
+
relative = path_obj.relative_to(root_path)
|
|
484
|
+
except ValueError:
|
|
485
|
+
relative = path_obj
|
|
486
|
+
|
|
487
|
+
parts = list(relative.parts[:-1])
|
|
488
|
+
lowered = [part.lower() for part in parts]
|
|
489
|
+
for marker in ("services", "service", "apps", "app", "packages", "modules"):
|
|
490
|
+
if marker in lowered:
|
|
491
|
+
index = lowered.index(marker)
|
|
492
|
+
if index + 1 < len(parts):
|
|
493
|
+
return _service_name(parts[index + 1])
|
|
494
|
+
|
|
495
|
+
candidates = [part for part in parts if part.lower() not in COMMON_PATH_PARTS]
|
|
496
|
+
if candidates:
|
|
497
|
+
return _service_name(candidates[-1])
|
|
498
|
+
return "default"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def infer_business_criticality(values: Iterable[str]) -> str:
|
|
502
|
+
text = " ".join(values).lower()
|
|
503
|
+
if any(hint in text for hint in CRITICAL_HINTS):
|
|
504
|
+
return "critical"
|
|
505
|
+
if any(hint in text for hint in HIGH_CRITICALITY_HINTS):
|
|
506
|
+
return "high"
|
|
507
|
+
if any(hint in text for hint in LOW_CRITICALITY_HINTS):
|
|
508
|
+
return "low"
|
|
509
|
+
return "medium"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _iter_files(root: Path, pattern: re.Pattern[str]) -> Iterable[Path]:
|
|
513
|
+
for path in sorted(root.rglob("*")):
|
|
514
|
+
if path.is_file() and pattern.match(path.name):
|
|
515
|
+
yield path
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _iter_cypress_files(root: Path) -> Iterable[Path]:
|
|
519
|
+
for path in sorted(root.rglob("*")):
|
|
520
|
+
if not path.is_file():
|
|
521
|
+
continue
|
|
522
|
+
if CYPRESS_FILE_RE.match(path.name):
|
|
523
|
+
yield path
|
|
524
|
+
continue
|
|
525
|
+
parts = {part.lower() for part in path.parts}
|
|
526
|
+
if "integration" in parts and path.suffix.lower() in {".js", ".ts"}:
|
|
527
|
+
yield path
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _relative_posix(path: Path, root: Path) -> str:
|
|
531
|
+
try:
|
|
532
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
533
|
+
except ValueError:
|
|
534
|
+
return path.as_posix()
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _suite_from_selector(selector: str) -> str:
|
|
538
|
+
parts = [part for part in selector.split("::") if part]
|
|
539
|
+
return parts[-2] if len(parts) > 1 else "default"
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _clean_name(value: str) -> str:
|
|
543
|
+
return re.sub(r"\s+", " ", value).strip()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _service_name(value: str) -> str:
|
|
547
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-").lower() or "default"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable, Mapping
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SUBSET_FRAMEWORKS = (
|
|
7
|
+
"pytest",
|
|
8
|
+
"jest",
|
|
9
|
+
"maven",
|
|
10
|
+
"gradle",
|
|
11
|
+
"cypress",
|
|
12
|
+
"playwright",
|
|
13
|
+
"rspec",
|
|
14
|
+
"cucumber",
|
|
15
|
+
"robot",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SubsetError(RuntimeError):
|
|
20
|
+
"""Raised when a selected subset cannot be rendered for test execution."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def selected_test_selectors(
|
|
24
|
+
response: Mapping[str, Any], *, use_internal_ids: bool = False
|
|
25
|
+
) -> list[str]:
|
|
26
|
+
selected = response.get("selected")
|
|
27
|
+
if selected is None:
|
|
28
|
+
raise SubsetError("Selection response did not include selected tests.")
|
|
29
|
+
if not isinstance(selected, list):
|
|
30
|
+
raise SubsetError("Selection response selected field was not a list.")
|
|
31
|
+
|
|
32
|
+
selectors: list[str] = []
|
|
33
|
+
for item in selected:
|
|
34
|
+
selector = _selector_from_item(item, use_internal_ids=use_internal_ids)
|
|
35
|
+
if selector:
|
|
36
|
+
selectors.append(selector)
|
|
37
|
+
return selectors
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_test_selectors(framework: str, selectors: Iterable[str]) -> str:
|
|
41
|
+
values = [str(selector).strip() for selector in selectors if str(selector).strip()]
|
|
42
|
+
if not values:
|
|
43
|
+
raise SubsetError("No tests were selected by Quantik Mind.")
|
|
44
|
+
|
|
45
|
+
if framework in {"pytest", "jest", "rspec", "cucumber", "robot"}:
|
|
46
|
+
return " ".join(values)
|
|
47
|
+
if framework == "maven":
|
|
48
|
+
return f"-Dtest={','.join(values)}"
|
|
49
|
+
if framework == "gradle":
|
|
50
|
+
return " ".join(f"--tests {value}" for value in values)
|
|
51
|
+
if framework == "cypress":
|
|
52
|
+
return f"--spec {','.join(values)}"
|
|
53
|
+
if framework == "playwright":
|
|
54
|
+
return " ".join(values)
|
|
55
|
+
raise SubsetError(f"Unsupported framework for qmind subset: {framework}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def selection_stats(response: Mapping[str, Any]) -> str | None:
|
|
59
|
+
counts = response.get("counts")
|
|
60
|
+
if not isinstance(counts, Mapping):
|
|
61
|
+
return None
|
|
62
|
+
total = counts.get("total")
|
|
63
|
+
selected = counts.get("selected")
|
|
64
|
+
if total is None or selected is None:
|
|
65
|
+
return None
|
|
66
|
+
return f"Quantik Mind selected {selected}/{total} tests."
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def machine_readable_selection_payload(
|
|
70
|
+
response: Mapping[str, Any],
|
|
71
|
+
*,
|
|
72
|
+
selected_tests: Iterable[str],
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
payload = dict(response)
|
|
75
|
+
payload.setdefault("selected_tests", list(selected_tests))
|
|
76
|
+
|
|
77
|
+
business_metrics = payload.get("business_metrics")
|
|
78
|
+
if isinstance(business_metrics, Mapping):
|
|
79
|
+
for key in (
|
|
80
|
+
"risk_coverage",
|
|
81
|
+
"top_risk_coverage",
|
|
82
|
+
"residual_risk",
|
|
83
|
+
"risk_efficiency",
|
|
84
|
+
):
|
|
85
|
+
if key in business_metrics:
|
|
86
|
+
payload.setdefault(key, business_metrics[key])
|
|
87
|
+
|
|
88
|
+
return payload
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _selector_from_item(item: Any, *, use_internal_ids: bool = False) -> str | None:
|
|
92
|
+
if isinstance(item, str):
|
|
93
|
+
return item.strip() or None
|
|
94
|
+
if not isinstance(item, Mapping):
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
keys = (
|
|
98
|
+
("internal_test_id", "id", "test_id")
|
|
99
|
+
if use_internal_ids
|
|
100
|
+
else ("test_id", "cli_selector", "test_name")
|
|
101
|
+
)
|
|
102
|
+
for key in keys:
|
|
103
|
+
value = item.get(key)
|
|
104
|
+
if value is not None and str(value).strip():
|
|
105
|
+
return str(value).strip()
|
|
106
|
+
return None
|