pytest-visionspec 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/PKG-INFO +18 -2
- {pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/README.md +17 -1
- {pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/pyproject.toml +1 -1
- {pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/pytest_visionspec/__init__.py +115 -17
- {pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/pytest_visionspec.egg-info/PKG-INFO +18 -2
- {pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/pytest_visionspec.egg-info/SOURCES.txt +0 -0
- {pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/pytest_visionspec.egg-info/dependency_links.txt +0 -0
- {pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/pytest_visionspec.egg-info/entry_points.txt +0 -0
- {pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/pytest_visionspec.egg-info/requires.txt +0 -0
- {pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/pytest_visionspec.egg-info/top_level.txt +0 -0
- {pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-visionspec
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Pytest plugin that auto-reports test results with screenshots to VisionSpec
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -46,8 +46,24 @@ The plugin automatically:
|
|
|
46
46
|
|
|
47
47
|
## Markers
|
|
48
48
|
|
|
49
|
-
- `@pytest.mark.vs("journey-id")` — link test to a VisionSpec journey spec
|
|
49
|
+
- `@pytest.mark.vs("journey-id")` — link test to a VisionSpec V1 journey spec
|
|
50
50
|
- `@pytest.mark.vs_surface("mobile")` — override the reported surface (default: `desktop`)
|
|
51
|
+
- `@pytest.mark.vs_v2("spec-slug", jtbd="jtbd-slug")` — link test to a VisionSpec V2 spec (chapters model). Results are buffered and POSTed in one batch at session end to `/api/v2/results`.
|
|
52
|
+
|
|
53
|
+
## V2 usage (chapters model)
|
|
54
|
+
|
|
55
|
+
For V2 projects, tag tests with `vs_v2` — no screenshot capture, batched reporting:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
@pytest.mark.vs_v2("verdict-returns-200", jtbd="get-verdict")
|
|
59
|
+
def test_verdict():
|
|
60
|
+
r = client.get("/verdict")
|
|
61
|
+
assert r.status_code == 200
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Get your project's `VS_API_KEY` via the V2 MCP tool `get_project(project)` (returns `test_reporting.env_vars`) or from the "CI / Test runner" tab in V2 project settings.
|
|
65
|
+
|
|
66
|
+
Results POST once per pytest session. Coverage in V2 `get_doc`'s `summary` field then shows `tested` / `passing` / `failing` counts per spec.
|
|
51
67
|
|
|
52
68
|
## Page fixture detection
|
|
53
69
|
|
|
@@ -37,8 +37,24 @@ The plugin automatically:
|
|
|
37
37
|
|
|
38
38
|
## Markers
|
|
39
39
|
|
|
40
|
-
- `@pytest.mark.vs("journey-id")` — link test to a VisionSpec journey spec
|
|
40
|
+
- `@pytest.mark.vs("journey-id")` — link test to a VisionSpec V1 journey spec
|
|
41
41
|
- `@pytest.mark.vs_surface("mobile")` — override the reported surface (default: `desktop`)
|
|
42
|
+
- `@pytest.mark.vs_v2("spec-slug", jtbd="jtbd-slug")` — link test to a VisionSpec V2 spec (chapters model). Results are buffered and POSTed in one batch at session end to `/api/v2/results`.
|
|
43
|
+
|
|
44
|
+
## V2 usage (chapters model)
|
|
45
|
+
|
|
46
|
+
For V2 projects, tag tests with `vs_v2` — no screenshot capture, batched reporting:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
@pytest.mark.vs_v2("verdict-returns-200", jtbd="get-verdict")
|
|
50
|
+
def test_verdict():
|
|
51
|
+
r = client.get("/verdict")
|
|
52
|
+
assert r.status_code == 200
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Get your project's `VS_API_KEY` via the V2 MCP tool `get_project(project)` (returns `test_reporting.env_vars`) or from the "CI / Test runner" tab in V2 project settings.
|
|
56
|
+
|
|
57
|
+
Results POST once per pytest session. Coverage in V2 `get_doc`'s `summary` field then shows `tested` / `passing` / `failing` counts per spec.
|
|
42
58
|
|
|
43
59
|
## Page fixture detection
|
|
44
60
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytest-visionspec"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Pytest plugin that auto-reports test results with screenshots to VisionSpec"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Usage:
|
|
4
4
|
pip install pytest-visionspec
|
|
5
5
|
Set env vars: VS_API_KEY, VS_API_URL
|
|
6
|
-
Tag tests: @pytest.mark.vs("journey-id")
|
|
6
|
+
Tag tests: @pytest.mark.vs("journey-id", revision="slug")
|
|
7
7
|
|
|
8
8
|
The plugin:
|
|
9
|
-
1. Detects @pytest.mark.vs("journey-id") tags on tests
|
|
9
|
+
1. Detects @pytest.mark.vs("journey-id", revision="slug") tags on tests
|
|
10
10
|
2. After each test, captures a Playwright screenshot (if a page fixture exists)
|
|
11
11
|
3. Uploads screenshots to VisionSpec storage
|
|
12
|
-
4. Posts the result (pass/fail + screenshot_urls +
|
|
12
|
+
4. Posts the result (pass/fail + screenshot_urls + screenshot_metadata) to VisionSpec
|
|
13
13
|
|
|
14
14
|
Labeled screenshots:
|
|
15
15
|
Use the vs_screenshot fixture to capture labeled screenshots mid-test:
|
|
@@ -20,7 +20,9 @@ Labeled screenshots:
|
|
|
20
20
|
vs_screenshot("dark mode") # captures + labels
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
|
+
import hashlib
|
|
23
24
|
import os
|
|
25
|
+
import struct
|
|
24
26
|
import sys
|
|
25
27
|
import glob
|
|
26
28
|
from pathlib import Path
|
|
@@ -35,6 +37,10 @@ _VERBOSE = os.environ.get("VS_VERBOSE", "").lower() in ("1", "true", "yes")
|
|
|
35
37
|
# Default fixture names; overridden by [tool.pytest-visionspec] page_fixtures in pyproject.toml
|
|
36
38
|
_PAGE_FIXTURES = ["page"]
|
|
37
39
|
|
|
40
|
+
# Session-scoped buffer for V2 spec results (@pytest.mark.vs_v2). Flushed
|
|
41
|
+
# in pytest_sessionfinish with a single batched POST to /api/v2/results.
|
|
42
|
+
_V2_BUFFER: list = []
|
|
43
|
+
|
|
38
44
|
|
|
39
45
|
def pytest_addoption(parser):
|
|
40
46
|
parser.addini("vs_page_fixtures",
|
|
@@ -46,6 +52,10 @@ def pytest_addoption(parser):
|
|
|
46
52
|
def pytest_configure(config):
|
|
47
53
|
config.addinivalue_line("markers", "vs(journey_id): link test to a VisionSpec journey")
|
|
48
54
|
config.addinivalue_line("markers", "vs_surface(surface): override the reported surface (api, desktop, mobile)")
|
|
55
|
+
config.addinivalue_line(
|
|
56
|
+
"markers",
|
|
57
|
+
"vs_v2(spec_slug, jtbd=None): report V2 spec test result to /api/v2/results",
|
|
58
|
+
)
|
|
49
59
|
|
|
50
60
|
global _PAGE_FIXTURES
|
|
51
61
|
_PAGE_FIXTURES = list(config.getini("vs_page_fixtures"))
|
|
@@ -95,6 +105,10 @@ def pytest_runtest_makereport(item, call):
|
|
|
95
105
|
if call.when != "call":
|
|
96
106
|
return
|
|
97
107
|
|
|
108
|
+
# V2 marker: buffer for session-end batch POST. Independent of the V1
|
|
109
|
+
# marker path below.
|
|
110
|
+
_buffer_vs_v2_result(item, report)
|
|
111
|
+
|
|
98
112
|
markers = list(item.iter_markers("vs"))
|
|
99
113
|
api_key = _API_KEY
|
|
100
114
|
api_url = _API_URL
|
|
@@ -124,9 +138,10 @@ def pytest_runtest_makereport(item, call):
|
|
|
124
138
|
# Upload any captured screenshots/videos
|
|
125
139
|
screenshot_urls = []
|
|
126
140
|
screenshot_labels = []
|
|
141
|
+
screenshot_metadata = []
|
|
127
142
|
video_url = None
|
|
128
143
|
try:
|
|
129
|
-
screenshot_urls, screenshot_labels, video_url = _collect_and_upload_artifacts(item, api_key, api_url)
|
|
144
|
+
screenshot_urls, screenshot_labels, screenshot_metadata, video_url = _collect_and_upload_artifacts(item, api_key, api_url)
|
|
130
145
|
except Exception:
|
|
131
146
|
pass
|
|
132
147
|
|
|
@@ -158,6 +173,7 @@ def pytest_runtest_makereport(item, call):
|
|
|
158
173
|
"versions": _get_versions(),
|
|
159
174
|
"screenshot_urls": screenshot_urls,
|
|
160
175
|
"screenshot_labels": screenshot_labels,
|
|
176
|
+
"screenshot_metadata": screenshot_metadata,
|
|
161
177
|
"video_url": video_url,
|
|
162
178
|
"revision_slug": revision,
|
|
163
179
|
})
|
|
@@ -184,22 +200,39 @@ def _safe_filename(name: str) -> str:
|
|
|
184
200
|
return name.replace("[", "-").replace("]", "").replace("/", "-").replace(":", "-")
|
|
185
201
|
|
|
186
202
|
|
|
203
|
+
def _png_dimensions(data: bytes) -> tuple:
|
|
204
|
+
"""Extract width and height from PNG header without Pillow."""
|
|
205
|
+
if len(data) < 24 or data[:8] != b'\x89PNG\r\n\x1a\n':
|
|
206
|
+
return (0, 0)
|
|
207
|
+
# IHDR chunk starts at byte 8: 4 bytes length + 4 bytes "IHDR" + 4 bytes width + 4 bytes height
|
|
208
|
+
width, height = struct.unpack('>II', data[16:24])
|
|
209
|
+
return (width, height)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _file_metadata(data: bytes, url: str) -> dict:
|
|
213
|
+
"""Build screenshot_metadata entry from file bytes and uploaded URL."""
|
|
214
|
+
width, height = _png_dimensions(data)
|
|
215
|
+
return {
|
|
216
|
+
"url": url,
|
|
217
|
+
"width": width,
|
|
218
|
+
"height": height,
|
|
219
|
+
"size_bytes": len(data),
|
|
220
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
187
224
|
def _collect_and_upload_artifacts(item, api_key, api_url):
|
|
188
225
|
"""Find screenshots/videos in test-results/ and upload them.
|
|
189
226
|
|
|
190
|
-
Returns (screenshot_urls, screenshot_labels, video_url).
|
|
191
|
-
Labels are extracted from filenames: 'labeled-N-some-label.png' -> 'some label'.
|
|
192
|
-
The final end-of-test screenshot gets label 'end'.
|
|
227
|
+
Returns (screenshot_urls, screenshot_labels, screenshot_metadata, video_url).
|
|
193
228
|
"""
|
|
194
229
|
screenshot_urls = []
|
|
195
230
|
screenshot_labels = []
|
|
231
|
+
screenshot_metadata = []
|
|
196
232
|
video_url = None
|
|
197
233
|
|
|
198
234
|
safe_name = _safe_filename(item.name)
|
|
199
235
|
|
|
200
|
-
# Get labeled captures from the vs_screenshot fixture (if used)
|
|
201
|
-
vs_labels = getattr(item, "_vs_screenshot_labels", [])
|
|
202
|
-
|
|
203
236
|
patterns = [
|
|
204
237
|
f"test-results/{safe_name}/*.png",
|
|
205
238
|
f"test-results/{safe_name}/*.jpg",
|
|
@@ -228,9 +261,10 @@ def _collect_and_upload_artifacts(item, api_key, api_url):
|
|
|
228
261
|
"webm": "video/webm", "mp4": "video/mp4",
|
|
229
262
|
}.get(ext, "application/octet-stream")
|
|
230
263
|
|
|
264
|
+
file_bytes = path.read_bytes()
|
|
231
265
|
upload_r = httpx.post(f"{api_url}/api/upload",
|
|
232
266
|
headers={"X-API-Key": api_key, "Content-Type": mime, "X-Filename": path.name},
|
|
233
|
-
content=
|
|
267
|
+
content=file_bytes,
|
|
234
268
|
timeout=30)
|
|
235
269
|
|
|
236
270
|
if upload_r.status_code == 200:
|
|
@@ -238,21 +272,20 @@ def _collect_and_upload_artifacts(item, api_key, api_url):
|
|
|
238
272
|
if mime.startswith("video/"):
|
|
239
273
|
video_url = data["url"]
|
|
240
274
|
else:
|
|
241
|
-
|
|
275
|
+
url = data["url"]
|
|
276
|
+
screenshot_urls.append(url)
|
|
277
|
+
screenshot_metadata.append(_file_metadata(file_bytes, url))
|
|
242
278
|
# Derive label from filename
|
|
243
279
|
stem = path.stem
|
|
244
280
|
if stem.startswith("labeled-") and len(stem.split("-", 2)) >= 3:
|
|
245
|
-
# labeled-0-some-label -> some label
|
|
246
281
|
label = stem.split("-", 2)[2].replace("-", " ")
|
|
247
|
-
elif stem
|
|
282
|
+
elif stem in ("end", "after"):
|
|
248
283
|
label = "end"
|
|
249
|
-
elif stem == "after":
|
|
250
|
-
label = "end" # legacy
|
|
251
284
|
else:
|
|
252
285
|
label = ""
|
|
253
286
|
screenshot_labels.append(label)
|
|
254
287
|
|
|
255
|
-
return screenshot_urls, screenshot_labels, video_url
|
|
288
|
+
return screenshot_urls, screenshot_labels, screenshot_metadata, video_url
|
|
256
289
|
|
|
257
290
|
|
|
258
291
|
def _get_versions():
|
|
@@ -273,3 +306,68 @@ def _get_versions():
|
|
|
273
306
|
except Exception:
|
|
274
307
|
pass
|
|
275
308
|
return versions
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------- V2: spec-result reporting via @pytest.mark.vs_v2 ----------
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _buffer_vs_v2_result(item, report):
|
|
315
|
+
"""If item is tagged @pytest.mark.vs_v2, append a result dict to
|
|
316
|
+
_V2_BUFFER for a later session-end batch POST."""
|
|
317
|
+
vs_v2_markers = list(item.iter_markers(name="vs_v2"))
|
|
318
|
+
if not vs_v2_markers:
|
|
319
|
+
return
|
|
320
|
+
m = vs_v2_markers[0]
|
|
321
|
+
spec_slug = m.args[0] if m.args else None
|
|
322
|
+
if not spec_slug:
|
|
323
|
+
return
|
|
324
|
+
jtbd_slug = m.kwargs.get("jtbd")
|
|
325
|
+
duration_ms = int(report.duration * 1000) if report.duration else None
|
|
326
|
+
error_message = None
|
|
327
|
+
if not report.passed:
|
|
328
|
+
error_message = (str(report.longreprtext) if report.longreprtext else "test failed")[:2000]
|
|
329
|
+
_V2_BUFFER.append({
|
|
330
|
+
"spec_slug": spec_slug,
|
|
331
|
+
"jtbd_slug": jtbd_slug,
|
|
332
|
+
"passed": report.passed,
|
|
333
|
+
"duration_ms": duration_ms,
|
|
334
|
+
"error_message": error_message,
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
339
|
+
"""Flush buffered V2 results in a single batch POST to /api/v2/results."""
|
|
340
|
+
if not _V2_BUFFER:
|
|
341
|
+
return
|
|
342
|
+
api_key = _API_KEY
|
|
343
|
+
api_url = _API_URL
|
|
344
|
+
if not api_key or not api_url:
|
|
345
|
+
if _VERBOSE:
|
|
346
|
+
print(
|
|
347
|
+
f"[visionspec] Skipping V2 results POST: "
|
|
348
|
+
f"api_key={'set' if api_key else 'MISSING'}, "
|
|
349
|
+
f"api_url={'set' if api_url else 'MISSING'}",
|
|
350
|
+
file=sys.stderr,
|
|
351
|
+
)
|
|
352
|
+
_V2_BUFFER.clear()
|
|
353
|
+
return
|
|
354
|
+
try:
|
|
355
|
+
r = httpx.post(
|
|
356
|
+
f"{api_url}/api/v2/results",
|
|
357
|
+
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
|
|
358
|
+
json={"results": list(_V2_BUFFER)},
|
|
359
|
+
timeout=30,
|
|
360
|
+
)
|
|
361
|
+
if _VERBOSE:
|
|
362
|
+
print(
|
|
363
|
+
f"[visionspec] V2 results POST: status={r.status_code}, "
|
|
364
|
+
f"buffered={len(_V2_BUFFER)}",
|
|
365
|
+
file=sys.stderr,
|
|
366
|
+
)
|
|
367
|
+
if r.status_code >= 400 and _VERBOSE:
|
|
368
|
+
print(f"[visionspec] V2 BODY: {r.text[:200]}", file=sys.stderr)
|
|
369
|
+
except Exception as e:
|
|
370
|
+
if _VERBOSE:
|
|
371
|
+
print(f"[visionspec] V2 results POST failed: {e}", file=sys.stderr)
|
|
372
|
+
finally:
|
|
373
|
+
_V2_BUFFER.clear()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-visionspec
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Pytest plugin that auto-reports test results with screenshots to VisionSpec
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -46,8 +46,24 @@ The plugin automatically:
|
|
|
46
46
|
|
|
47
47
|
## Markers
|
|
48
48
|
|
|
49
|
-
- `@pytest.mark.vs("journey-id")` — link test to a VisionSpec journey spec
|
|
49
|
+
- `@pytest.mark.vs("journey-id")` — link test to a VisionSpec V1 journey spec
|
|
50
50
|
- `@pytest.mark.vs_surface("mobile")` — override the reported surface (default: `desktop`)
|
|
51
|
+
- `@pytest.mark.vs_v2("spec-slug", jtbd="jtbd-slug")` — link test to a VisionSpec V2 spec (chapters model). Results are buffered and POSTed in one batch at session end to `/api/v2/results`.
|
|
52
|
+
|
|
53
|
+
## V2 usage (chapters model)
|
|
54
|
+
|
|
55
|
+
For V2 projects, tag tests with `vs_v2` — no screenshot capture, batched reporting:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
@pytest.mark.vs_v2("verdict-returns-200", jtbd="get-verdict")
|
|
59
|
+
def test_verdict():
|
|
60
|
+
r = client.get("/verdict")
|
|
61
|
+
assert r.status_code == 200
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Get your project's `VS_API_KEY` via the V2 MCP tool `get_project(project)` (returns `test_reporting.env_vars`) or from the "CI / Test runner" tab in V2 project settings.
|
|
65
|
+
|
|
66
|
+
Results POST once per pytest session. Coverage in V2 `get_doc`'s `summary` field then shows `tested` / `passing` / `failing` counts per spec.
|
|
51
67
|
|
|
52
68
|
## Page fixture detection
|
|
53
69
|
|
|
File without changes
|
{pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/pytest_visionspec.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/pytest_visionspec.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{pytest_visionspec-0.2.0 → pytest_visionspec-0.3.0}/pytest_visionspec.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|