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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-devant-cloud
3
- Version: 0.1.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.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