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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-visionspec
3
- Version: 0.2.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.2.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 + screenshot_labels) to VisionSpec
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=path.read_bytes(),
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
- screenshot_urls.append(data["url"])
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 == "end":
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.2.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