pytest-devant-cloud 0.1.1__tar.gz → 0.1.2__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_devant_cloud-0.1.1 → pytest_devant_cloud-0.1.2}/PKG-INFO +1 -1
- {pytest_devant_cloud-0.1.1 → pytest_devant_cloud-0.1.2}/pyproject.toml +1 -1
- {pytest_devant_cloud-0.1.1 → pytest_devant_cloud-0.1.2}/src/pytest_devant_cloud/client.py +36 -1
- {pytest_devant_cloud-0.1.1 → pytest_devant_cloud-0.1.2}/src/pytest_devant_cloud/plugin.py +98 -1
- {pytest_devant_cloud-0.1.1 → pytest_devant_cloud-0.1.2}/.gitignore +0 -0
- {pytest_devant_cloud-0.1.1 → pytest_devant_cloud-0.1.2}/LICENSE +0 -0
- {pytest_devant_cloud-0.1.1 → pytest_devant_cloud-0.1.2}/README.md +0 -0
- {pytest_devant_cloud-0.1.1 → pytest_devant_cloud-0.1.2}/src/pytest_devant_cloud/__init__.py +0 -0
- {pytest_devant_cloud-0.1.1 → pytest_devant_cloud-0.1.2}/src/pytest_devant_cloud/mapping.py +0 -0
- {pytest_devant_cloud-0.1.1 → pytest_devant_cloud-0.1.2}/tests/test_mapping.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-devant-cloud
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: pytest plugin that streams runs, results, and step trees to Devant Cloud's /v1/runs API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/devant-net/devq-cloud/tree/main/packages/pytest-devant-cloud
|
|
6
6
|
Project-URL: Repository, https://github.com/devant-net/devq-cloud
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytest-devant-cloud"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "pytest plugin that streams runs, results, and step trees to Devant Cloud's /v1/runs API."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -8,6 +8,7 @@ wire-level concerns (auth, retries, JSON encoding).
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import os
|
|
11
12
|
import time
|
|
12
13
|
from dataclasses import dataclass
|
|
13
14
|
from typing import Any
|
|
@@ -52,10 +53,14 @@ class DevqClient:
|
|
|
52
53
|
# Re-use one Connection pool for the whole session — pytest can
|
|
53
54
|
# easily emit hundreds of POSTs in a fast suite, and TCP setup
|
|
54
55
|
# cost dwarfs the request itself otherwise.
|
|
56
|
+
# Content-Type intentionally NOT set here: httpx infers
|
|
57
|
+
# `application/json` from the `json=` kwarg used by _request(), and
|
|
58
|
+
# leaves multipart boundaries alone for `files=`-based uploads. A
|
|
59
|
+
# static client-level Content-Type would clobber the multipart
|
|
60
|
+
# boundary header on artifact uploads.
|
|
55
61
|
self._http = httpx.Client(
|
|
56
62
|
headers={
|
|
57
63
|
"Authorization": f"Bearer {api_token}",
|
|
58
|
-
"Content-Type": "application/json",
|
|
59
64
|
"Accept": "application/json",
|
|
60
65
|
},
|
|
61
66
|
timeout=timeout,
|
|
@@ -210,6 +215,36 @@ class DevqClient:
|
|
|
210
215
|
body = resp.json()
|
|
211
216
|
return body.get("results", [])
|
|
212
217
|
|
|
218
|
+
def upload_artifact(
|
|
219
|
+
self,
|
|
220
|
+
run_id: int,
|
|
221
|
+
result_id: int,
|
|
222
|
+
attempt_id: int,
|
|
223
|
+
*,
|
|
224
|
+
path: str,
|
|
225
|
+
name: str,
|
|
226
|
+
content_type: str,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Multipart POST a single file to the run/result/attempt's artifacts.
|
|
229
|
+
|
|
230
|
+
Mirrors @devant-net/reporter-core's `uploadArtifacts` wire shape:
|
|
231
|
+
one `file` part with the bytes, one `name` text part with the
|
|
232
|
+
display name shown in the Devant Cloud UI.
|
|
233
|
+
"""
|
|
234
|
+
with open(path, "rb") as fh:
|
|
235
|
+
file_bytes = fh.read()
|
|
236
|
+
# Reuse the configured httpx client (auth header, base url, timeouts)
|
|
237
|
+
# but bypass `_request`'s JSON-only path.
|
|
238
|
+
resp = self._http.post(
|
|
239
|
+
f"{self.api_url}/v1/runs/{run_id}/results/{result_id}/attempts/{attempt_id}/artifacts",
|
|
240
|
+
files={"file": (os.path.basename(path), file_bytes, content_type)},
|
|
241
|
+
data={"name": name},
|
|
242
|
+
)
|
|
243
|
+
if resp.status_code >= 400:
|
|
244
|
+
raise RuntimeError(
|
|
245
|
+
f"[devant-reporter] artifact upload {resp.status_code}: {resp.text[:200]}"
|
|
246
|
+
)
|
|
247
|
+
|
|
213
248
|
def submit_coverage(
|
|
214
249
|
self,
|
|
215
250
|
run_id: int,
|
|
@@ -502,4 +502,101 @@ def _submit_one(
|
|
|
502
502
|
}
|
|
503
503
|
],
|
|
504
504
|
}
|
|
505
|
-
state.client.submit_results(state.run_id, [submit_payload])
|
|
505
|
+
inserted = state.client.submit_results(state.run_id, [submit_payload])
|
|
506
|
+
_maybe_upload_playwright_artifacts(state, item, inserted)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# ── Playwright (pytest-playwright) artifact integration ─────────────────────
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _maybe_upload_playwright_artifacts(
|
|
513
|
+
state: _State,
|
|
514
|
+
item: pytest.Item,
|
|
515
|
+
inserted: list[dict[str, Any]],
|
|
516
|
+
) -> None:
|
|
517
|
+
"""If pytest-playwright wrote artifacts for this test, upload them.
|
|
518
|
+
|
|
519
|
+
pytest-playwright stores per-test artifacts at:
|
|
520
|
+
<--output dir>/<slugified-nodeid>/
|
|
521
|
+
trace.zip (--tracing on / retain-on-failure)
|
|
522
|
+
video.webm (--video on / retain-on-failure)
|
|
523
|
+
test-failed-N.png (--screenshot only-on-failure / on)
|
|
524
|
+
|
|
525
|
+
We compute the same path using pytest-playwright's own helpers (so the
|
|
526
|
+
slug formula stays in lockstep with upstream) and POST every file
|
|
527
|
+
inside as a multipart artifact attached to attempt 1.
|
|
528
|
+
|
|
529
|
+
Silent no-op when pytest-playwright isn't installed.
|
|
530
|
+
"""
|
|
531
|
+
try:
|
|
532
|
+
from pytest_playwright.pytest_playwright import _truncate_file_name # type: ignore
|
|
533
|
+
from slugify import slugify # type: ignore
|
|
534
|
+
except ImportError:
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
if not inserted:
|
|
538
|
+
return
|
|
539
|
+
record = inserted[0]
|
|
540
|
+
result_id = record.get("result_id")
|
|
541
|
+
attempts = record.get("attempt_ids", [])
|
|
542
|
+
if not result_id or not attempts:
|
|
543
|
+
return
|
|
544
|
+
attempt_id = attempts[0].get("id")
|
|
545
|
+
if not attempt_id:
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
output_root = item.session.config.getoption("--output", default="test-results")
|
|
550
|
+
except (ValueError, AttributeError):
|
|
551
|
+
return
|
|
552
|
+
from pathlib import Path
|
|
553
|
+
|
|
554
|
+
output_dir = Path(output_root).absolute() / _truncate_file_name(slugify(item.nodeid))
|
|
555
|
+
if not output_dir.is_dir():
|
|
556
|
+
return # no artifacts captured for this test (passed cleanly, etc.)
|
|
557
|
+
|
|
558
|
+
for path in sorted(output_dir.iterdir()):
|
|
559
|
+
if not path.is_file():
|
|
560
|
+
continue
|
|
561
|
+
try:
|
|
562
|
+
content_type = _guess_content_type(path)
|
|
563
|
+
display = _artifact_display_name(path)
|
|
564
|
+
state.client.upload_artifact(
|
|
565
|
+
state.run_id or 0,
|
|
566
|
+
int(result_id),
|
|
567
|
+
int(attempt_id),
|
|
568
|
+
path=str(path),
|
|
569
|
+
name=display,
|
|
570
|
+
content_type=content_type,
|
|
571
|
+
)
|
|
572
|
+
except Exception as exc: # noqa: BLE001
|
|
573
|
+
sys.stderr.write(
|
|
574
|
+
f'[devant] failed to upload "{path.name}" for "{item.nodeid}": {exc}\n'
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _guess_content_type(path: "Path") -> str: # noqa: F821 (Path imported above)
|
|
579
|
+
import mimetypes
|
|
580
|
+
|
|
581
|
+
suffix = path.suffix.lower()
|
|
582
|
+
if suffix == ".zip":
|
|
583
|
+
return "application/zip"
|
|
584
|
+
if suffix == ".webm":
|
|
585
|
+
return "video/webm"
|
|
586
|
+
if suffix == ".png":
|
|
587
|
+
return "image/png"
|
|
588
|
+
if suffix in (".jpg", ".jpeg"):
|
|
589
|
+
return "image/jpeg"
|
|
590
|
+
guessed, _ = mimetypes.guess_type(str(path))
|
|
591
|
+
return guessed or "application/octet-stream"
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _artifact_display_name(path: "Path") -> str: # noqa: F821
|
|
595
|
+
"""UI-friendly artifact name. `trace*.zip` gets normalised to `trace.zip`
|
|
596
|
+
so the Devant Cloud UI's trace-viewer detection works regardless of
|
|
597
|
+
upstream filename quirks (Playwright writes `trace-1.zip` for multi-page
|
|
598
|
+
tests)."""
|
|
599
|
+
name = path.name
|
|
600
|
+
if path.suffix.lower() == ".zip" and "trace" in name.lower():
|
|
601
|
+
return "trace.zip"
|
|
602
|
+
return name
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|