pytest-visionspec 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.
@@ -0,0 +1,194 @@
1
+ """pytest-visionspec — auto-report test results with screenshot uploads.
2
+
3
+ Usage:
4
+ pip install pytest-visionspec
5
+ Set env vars: VS_API_KEY, VS_API_URL
6
+ Tag tests: @pytest.mark.vs("journey-id")
7
+
8
+ The plugin:
9
+ 1. Detects @pytest.mark.vs("journey-id") tags on tests
10
+ 2. After each test, captures a Playwright screenshot (if a page fixture exists)
11
+ 3. Uploads screenshots to VisionSpec storage
12
+ 4. Posts the result (pass/fail + screenshot_urls) to VisionSpec
13
+ """
14
+
15
+ import os
16
+ import sys
17
+ import glob
18
+ from pathlib import Path
19
+
20
+ import httpx
21
+ import pytest
22
+
23
+ _API_KEY = os.environ.get("VS_API_KEY", "")
24
+ _API_URL = os.environ.get("VS_API_URL", "")
25
+ _VERBOSE = os.environ.get("VS_VERBOSE", "").lower() in ("1", "true", "yes")
26
+
27
+ # Default fixture names; overridden by [tool.pytest-visionspec] page_fixtures in pyproject.toml
28
+ _PAGE_FIXTURES = ["page"]
29
+
30
+
31
+ def pytest_addoption(parser):
32
+ parser.addini("vs_page_fixtures",
33
+ type="args",
34
+ default=["page"],
35
+ help="Fixture names to check for a Playwright page object (in priority order)")
36
+
37
+
38
+ def pytest_configure(config):
39
+ config.addinivalue_line("markers", "vs(journey_id): link test to a VisionSpec journey")
40
+ config.addinivalue_line("markers", "vs_surface(surface): override the reported surface (api, desktop, mobile)")
41
+
42
+ global _PAGE_FIXTURES
43
+ _PAGE_FIXTURES = list(config.getini("vs_page_fixtures"))
44
+
45
+
46
+ @pytest.hookimpl(tryfirst=True, hookwrapper=True)
47
+ def pytest_runtest_makereport(item, call):
48
+ outcome = yield
49
+ report = outcome.get_result()
50
+ if call.when != "call":
51
+ return
52
+
53
+ markers = list(item.iter_markers("vs"))
54
+ api_key = _API_KEY
55
+ api_url = _API_URL
56
+ if not markers or not api_key or not api_url:
57
+ return
58
+
59
+ # Determine surface
60
+ surface_marker = item.get_closest_marker("vs_surface")
61
+ if surface_marker and surface_marker.args:
62
+ surface = surface_marker.args[0]
63
+ else:
64
+ surface = "desktop"
65
+
66
+ # Capture screenshot if a Playwright page fixture is available
67
+ screenshot_urls = []
68
+ video_url = None
69
+ page = _find_page(item)
70
+ if page:
71
+ try:
72
+ safe_name = _safe_filename(item.name)
73
+ screenshot_dir = Path("test-results") / safe_name
74
+ screenshot_dir.mkdir(parents=True, exist_ok=True)
75
+ screenshot_path = screenshot_dir / "after.png"
76
+ raw = page.raw_page if hasattr(page, "raw_page") else page
77
+ raw.screenshot(path=str(screenshot_path))
78
+ except Exception:
79
+ pass
80
+
81
+ # Upload any captured screenshots/videos
82
+ try:
83
+ screenshot_urls, video_url = _collect_and_upload_artifacts(item, api_key, api_url)
84
+ except Exception:
85
+ pass
86
+
87
+ # Report result for each tagged journey
88
+ headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
89
+ for marker in markers:
90
+ journey_id = marker.args[0] if marker.args else None
91
+ if not journey_id:
92
+ continue
93
+
94
+ # Read revision from marker kwargs or VS_REVISION env var
95
+ revision = marker.kwargs.get("revision") or os.environ.get("VS_REVISION") or None
96
+ if not revision:
97
+ print(f"[visionspec] ERROR: @pytest.mark.vs('{journey_id}', revision='slug') "
98
+ f"requires revision parameter — skipping result post", file=sys.stderr)
99
+ continue
100
+
101
+ try:
102
+ r = httpx.post(f"{api_url}/api/results",
103
+ headers=headers, timeout=10,
104
+ json={
105
+ "journey_id": journey_id,
106
+ "journey_version": 1,
107
+ "surface": surface,
108
+ "sut_version": os.environ.get("VS_SUT_VERSION", "dev"),
109
+ "passed": report.passed,
110
+ "steps": [{"action": item.name, "passed": report.passed}],
111
+ "source": "pytest-visionspec",
112
+ "versions": _get_versions(),
113
+ "screenshot_urls": screenshot_urls,
114
+ "video_url": video_url,
115
+ "revision_slug": revision,
116
+ })
117
+ if _VERBOSE:
118
+ print(f"[visionspec] POST {journey_id}: {r.status_code}", file=sys.stderr)
119
+ if r.status_code != 200 and _VERBOSE:
120
+ print(f"[visionspec] BODY: {r.text[:200]}", file=sys.stderr)
121
+ except Exception as e:
122
+ if _VERBOSE:
123
+ print(f"[visionspec] ERROR for {journey_id}: {e}", file=sys.stderr)
124
+
125
+
126
+ def _find_page(item):
127
+ """Find a Playwright page object from test fixtures."""
128
+ for name in _PAGE_FIXTURES:
129
+ page = item.funcargs.get(name)
130
+ if page is not None:
131
+ return page
132
+ return None
133
+
134
+
135
+ def _safe_filename(name: str) -> str:
136
+ """Sanitize a test name for use as a directory/file name."""
137
+ return name.replace("[", "-").replace("]", "").replace("/", "-").replace(":", "-")
138
+
139
+
140
+ def _collect_and_upload_artifacts(item, api_key, api_url):
141
+ """Find screenshots/videos in test-results/ and upload them."""
142
+ screenshot_urls = []
143
+ video_url = None
144
+
145
+ safe_name = _safe_filename(item.name)
146
+ patterns = [
147
+ f"test-results/*{safe_name}*/*.png",
148
+ f"test-results/*{safe_name}*/*.jpg",
149
+ f"test-results/*{safe_name}*/*.webm",
150
+ f"test-results/*{safe_name}*/*.mp4",
151
+ ]
152
+
153
+ for pattern in patterns:
154
+ for filepath in glob.glob(pattern):
155
+ path = Path(filepath)
156
+ ext = path.suffix.lstrip(".")
157
+ mime = {
158
+ "png": "image/png", "jpg": "image/jpeg",
159
+ "webm": "video/webm", "mp4": "video/mp4",
160
+ }.get(ext, "application/octet-stream")
161
+
162
+ upload_r = httpx.post(f"{api_url}/api/upload",
163
+ headers={"X-API-Key": api_key, "Content-Type": mime, "X-Filename": path.name},
164
+ content=path.read_bytes(),
165
+ timeout=30)
166
+
167
+ if upload_r.status_code == 200:
168
+ data = upload_r.json()
169
+ if mime.startswith("video/"):
170
+ video_url = data["url"]
171
+ else:
172
+ screenshot_urls.append(data["url"])
173
+
174
+ return screenshot_urls, video_url
175
+
176
+
177
+ def _get_versions():
178
+ """Build version metadata from environment, plus git commit hash."""
179
+ import subprocess
180
+ versions = {}
181
+ for key in ("VS_VERSION", "VS_FRONTEND_VERSION", "VS_BACKEND_VERSION"):
182
+ val = os.environ.get(key, "")
183
+ if val:
184
+ versions[key.replace("VS_", "").lower()] = val
185
+ # Auto-capture git commit
186
+ try:
187
+ result = subprocess.run(
188
+ ["git", "rev-parse", "--short", "HEAD"],
189
+ capture_output=True, text=True, timeout=5)
190
+ if result.returncode == 0 and result.stdout.strip():
191
+ versions["git"] = result.stdout.strip()
192
+ except Exception:
193
+ pass
194
+ return versions
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-visionspec
3
+ Version: 0.1.0
4
+ Summary: Pytest plugin that auto-reports test results with screenshots to VisionSpec
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pytest>=7.0
8
+ Requires-Dist: httpx>=0.24
9
+
10
+ # pytest-visionspec
11
+
12
+ Pytest plugin that auto-reports test results with screenshots to [VisionSpec](https://visionspec-dev.helpfulhuman.xyz).
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install pytest-visionspec
18
+ ```
19
+
20
+ ## Setup
21
+
22
+ Set two environment variables:
23
+
24
+ ```bash
25
+ VS_API_KEY=vs_your_project_api_key
26
+ VS_API_URL=https://visionspec-dev.helpfulhuman.xyz
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Tag tests with `@pytest.mark.vs("journey-id")`:
32
+
33
+ ```python
34
+ @pytest.mark.vs("login-happy-path")
35
+ def test_login(page):
36
+ page.goto("/login")
37
+ page.fill("[name=email]", "user@example.com")
38
+ page.click("button[type=submit]")
39
+ assert page.url == "/dashboard"
40
+ ```
41
+
42
+ The plugin automatically:
43
+ 1. Captures a Playwright screenshot after each test
44
+ 2. Uploads it to VisionSpec storage
45
+ 3. Posts the result (pass/fail + screenshot URL) to VisionSpec
46
+
47
+ ## Markers
48
+
49
+ - `@pytest.mark.vs("journey-id")` — link test to a VisionSpec journey spec
50
+ - `@pytest.mark.vs_surface("mobile")` — override the reported surface (default: `desktop`)
51
+
52
+ ## Page fixture detection
53
+
54
+ By default, the plugin looks for a `page` fixture. Configure custom fixture names in `pyproject.toml`:
55
+
56
+ ```toml
57
+ [tool.pytest.ini_options]
58
+ vs_page_fixtures = ["v2app", "v1app", "page"]
59
+ ```
60
+
61
+ The plugin checks each name in order and uses the first one it finds.
62
+
63
+ ## Environment variables
64
+
65
+ | Variable | Required | Description |
66
+ |---|---|---|
67
+ | `VS_API_KEY` | Yes | Project API key from VisionSpec |
68
+ | `VS_API_URL` | Yes | VisionSpec server URL |
69
+ | `VS_SUT_VERSION` | No | Version of the system under test |
70
+ | `VS_VERBOSE` | No | Set to `1` for debug logging |
71
+ | `VS_VERSION` | No | App version metadata |
72
+ | `VS_FRONTEND_VERSION` | No | Frontend version metadata |
73
+ | `VS_BACKEND_VERSION` | No | Backend version metadata |
@@ -0,0 +1,6 @@
1
+ pytest_visionspec/__init__.py,sha256=suKHQB71hESOic8EcxsFUsldr32BN-MOUdG1jkfuTdA,6827
2
+ pytest_visionspec-0.1.0.dist-info/METADATA,sha256=JpYaB6Exn30mwD8w7_a0Myw7LKL7Hrg6AUHlb70jJeo,1987
3
+ pytest_visionspec-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
4
+ pytest_visionspec-0.1.0.dist-info/entry_points.txt,sha256=N7WW51B1dZpnp7ncmivHZOFXdQoHOC5fhTc1HfKKCFY,42
5
+ pytest_visionspec-0.1.0.dist-info/top_level.txt,sha256=uTu3Hekp-czpqItRKrcWp6nyhO8SlQFs1FU2RWAC5FQ,18
6
+ pytest_visionspec-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ visionspec = pytest_visionspec
@@ -0,0 +1 @@
1
+ pytest_visionspec