testrelic-playwright 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,61 @@
1
+ """TestRelic Playwright analytics — pytest plugin and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from testrelic_playwright._version import __version__
6
+ from testrelic_playwright.config import (
7
+ DEFAULT_REDACT_BODY_FIELDS,
8
+ DEFAULT_REDACT_HEADERS,
9
+ DEFAULT_REDACTION_PATTERNS,
10
+ ReporterConfig,
11
+ resolve_config,
12
+ )
13
+ from testrelic_playwright.merge import merge_reports
14
+ from testrelic_playwright.reporter import record_navigation
15
+ from testrelic_playwright.schema import (
16
+ ATTACHMENT_CONTENT_TYPE,
17
+ ATTACHMENT_NAME,
18
+ PAYLOAD_VERSION,
19
+ SCHEMA_VERSION,
20
+ )
21
+ from testrelic_playwright.types import (
22
+ ApiAssertion,
23
+ ApiCallRecord,
24
+ AssertionLocation,
25
+ AssertionType,
26
+ CIMetadata,
27
+ FailureDiagnostic,
28
+ NavigationType,
29
+ NetworkStats,
30
+ Summary,
31
+ TestResult,
32
+ TestRunReport,
33
+ TimelineEntry,
34
+ )
35
+
36
+ __all__ = [
37
+ "ATTACHMENT_CONTENT_TYPE",
38
+ "ATTACHMENT_NAME",
39
+ "ApiAssertion",
40
+ "ApiCallRecord",
41
+ "AssertionLocation",
42
+ "AssertionType",
43
+ "CIMetadata",
44
+ "DEFAULT_REDACTION_PATTERNS",
45
+ "DEFAULT_REDACT_BODY_FIELDS",
46
+ "DEFAULT_REDACT_HEADERS",
47
+ "FailureDiagnostic",
48
+ "NavigationType",
49
+ "NetworkStats",
50
+ "PAYLOAD_VERSION",
51
+ "ReporterConfig",
52
+ "SCHEMA_VERSION",
53
+ "Summary",
54
+ "TestResult",
55
+ "TestRunReport",
56
+ "TimelineEntry",
57
+ "__version__",
58
+ "merge_reports",
59
+ "record_navigation",
60
+ "resolve_config",
61
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,140 @@
1
+ """Track Playwright network requests/responses for the active test."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Optional
9
+ from urllib.parse import urlparse
10
+
11
+ from testrelic_playwright.config import ReporterConfig
12
+ from testrelic_playwright.redaction import redact_body, redact_headers, redact_text
13
+ from testrelic_playwright.types import ApiCallRecord, NetworkStats
14
+
15
+
16
+ def _match_any(url: str, patterns: list[str]) -> bool:
17
+ if not patterns:
18
+ return False
19
+ parsed = urlparse(url)
20
+ needle = parsed.path or url
21
+ for pat in patterns:
22
+ if "*" in pat:
23
+ simplified = pat.replace("**", "*").replace("*", "")
24
+ if simplified and simplified in url:
25
+ return True
26
+ elif pat in url or pat in needle:
27
+ return True
28
+ return False
29
+
30
+
31
+ class ApiRequestTracker:
32
+ """Per-test buffer of HTTP requests with optional URL allow/deny lists."""
33
+
34
+ def __init__(self, config: ReporterConfig) -> None:
35
+ self._config = config
36
+ self._calls: list[ApiCallRecord] = []
37
+ self._pending: dict[int, dict[str, Any]] = {}
38
+ self._stats = {"totalRequests": 0, "failedRequests": 0, "totalBytes": 0}
39
+ self._by_type: dict[str, int] = {}
40
+
41
+ @property
42
+ def calls(self) -> list[ApiCallRecord]:
43
+ return list(self._calls)
44
+
45
+ def network_stats(self) -> NetworkStats:
46
+ return NetworkStats(
47
+ totalRequests=self._stats["totalRequests"],
48
+ failedRequests=self._stats["failedRequests"],
49
+ totalBytes=self._stats["totalBytes"],
50
+ byType=dict(self._by_type),
51
+ )
52
+
53
+ def on_request(self, request: Any) -> None:
54
+ if not self._config.track_api_calls:
55
+ return
56
+ url = getattr(request, "url", "")
57
+ if self._config.api_include_urls and not _match_any(url, self._config.api_include_urls):
58
+ return
59
+ if _match_any(url, self._config.api_exclude_urls):
60
+ return
61
+
62
+ self._stats["totalRequests"] += 1
63
+ rtype = getattr(request, "resource_type", None) or "other"
64
+ self._by_type[rtype] = self._by_type.get(rtype, 0) + 1
65
+
66
+ entry: dict[str, Any] = {
67
+ "method": getattr(request, "method", "GET"),
68
+ "url": url,
69
+ "startedAt": datetime.now(timezone.utc).isoformat(),
70
+ "_start": time.monotonic(),
71
+ }
72
+ if self._config.capture_request_headers:
73
+ entry["requestHeaders"] = redact_headers(
74
+ dict(getattr(request, "headers", {}) or {}),
75
+ self._config.redact_headers,
76
+ )
77
+ if self._config.capture_request_body:
78
+ entry["requestBody"] = self._capture_body(getattr(request, "post_data", None))
79
+ self._pending[id(request)] = entry
80
+
81
+ def on_response(self, response: Any) -> None:
82
+ request = getattr(response, "request", None)
83
+ if request is None:
84
+ return
85
+ entry = self._pending.pop(id(request), None)
86
+ if entry is None:
87
+ return
88
+
89
+ status = getattr(response, "status", None)
90
+ if isinstance(status, int) and status >= 400:
91
+ self._stats["failedRequests"] += 1
92
+
93
+ try:
94
+ body_bytes = response.body() if self._config.capture_response_body else b""
95
+ except Exception: # noqa: BLE001
96
+ body_bytes = b""
97
+
98
+ self._stats["totalBytes"] += len(body_bytes)
99
+
100
+ entry["statusCode"] = status
101
+ entry["durationMs"] = round((time.monotonic() - entry.pop("_start")) * 1000, 2)
102
+
103
+ if self._config.capture_response_headers:
104
+ entry["responseHeaders"] = redact_headers(
105
+ dict(getattr(response, "headers", {}) or {}),
106
+ self._config.redact_headers,
107
+ )
108
+
109
+ if self._config.capture_response_body:
110
+ entry["responseBody"] = self._capture_body(body_bytes)
111
+
112
+ self._calls.append(ApiCallRecord.model_validate(entry))
113
+
114
+ def on_request_failed(self, request: Any) -> None:
115
+ entry = self._pending.pop(id(request), None)
116
+ self._stats["failedRequests"] += 1
117
+ if entry is None:
118
+ return
119
+ entry["statusCode"] = None
120
+ entry["durationMs"] = round((time.monotonic() - entry.pop("_start")) * 1000, 2)
121
+ entry["responseBody"] = {"error": getattr(request, "failure", None)}
122
+ self._calls.append(ApiCallRecord.model_validate(entry))
123
+
124
+ def _capture_body(self, raw: Any) -> Any:
125
+ if raw is None:
126
+ return None
127
+ if isinstance(raw, bytes):
128
+ try:
129
+ raw = raw.decode("utf-8")
130
+ except UnicodeDecodeError:
131
+ return f"<{len(raw)} bytes binary>"
132
+ if isinstance(raw, str):
133
+ try:
134
+ parsed = json.loads(raw)
135
+ return redact_body(parsed, self._config.redact_body_fields)
136
+ except (TypeError, ValueError):
137
+ return redact_text(raw, self._config.resolved_redact_patterns)
138
+ if isinstance(raw, (dict, list)):
139
+ return redact_body(raw, self._config.redact_body_fields)
140
+ return raw
@@ -0,0 +1,82 @@
1
+ """Detect CI provider and pull build metadata from environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Optional
7
+
8
+ from testrelic_playwright.types import CIMetadata
9
+
10
+
11
+ def detect_ci() -> Optional[CIMetadata]:
12
+ """Return a populated `CIMetadata` if we recognise the CI provider."""
13
+ env = os.environ
14
+
15
+ if env.get("GITHUB_ACTIONS") == "true":
16
+ return CIMetadata(
17
+ provider="github-actions",
18
+ buildId=env.get("GITHUB_RUN_ID"),
19
+ commitSha=env.get("GITHUB_SHA"),
20
+ branch=env.get("GITHUB_REF_NAME"),
21
+ prNumber=_extract_pr_number(env.get("GITHUB_REF")),
22
+ workflow=env.get("GITHUB_WORKFLOW"),
23
+ runUrl=_github_run_url(env),
24
+ )
25
+
26
+ if env.get("GITLAB_CI") == "true":
27
+ return CIMetadata(
28
+ provider="gitlab-ci",
29
+ buildId=env.get("CI_PIPELINE_ID"),
30
+ commitSha=env.get("CI_COMMIT_SHA"),
31
+ branch=env.get("CI_COMMIT_REF_NAME"),
32
+ prNumber=env.get("CI_MERGE_REQUEST_IID"),
33
+ workflow=env.get("CI_JOB_NAME"),
34
+ runUrl=env.get("CI_PIPELINE_URL"),
35
+ )
36
+
37
+ if env.get("JENKINS_URL"):
38
+ return CIMetadata(
39
+ provider="jenkins",
40
+ buildId=env.get("BUILD_ID") or env.get("BUILD_NUMBER"),
41
+ commitSha=env.get("GIT_COMMIT"),
42
+ branch=env.get("GIT_BRANCH"),
43
+ workflow=env.get("JOB_NAME"),
44
+ runUrl=env.get("BUILD_URL"),
45
+ )
46
+
47
+ if env.get("CIRCLECI") == "true":
48
+ return CIMetadata(
49
+ provider="circleci",
50
+ buildId=env.get("CIRCLE_BUILD_NUM"),
51
+ commitSha=env.get("CIRCLE_SHA1"),
52
+ branch=env.get("CIRCLE_BRANCH"),
53
+ prNumber=_pr_from_url(env.get("CIRCLE_PULL_REQUEST")),
54
+ workflow=env.get("CIRCLE_JOB"),
55
+ runUrl=env.get("CIRCLE_BUILD_URL"),
56
+ )
57
+
58
+ return None
59
+
60
+
61
+ def _extract_pr_number(ref: Optional[str]) -> Optional[str]:
62
+ if not ref:
63
+ return None
64
+ parts = ref.split("/")
65
+ if len(parts) >= 3 and parts[1] == "pull":
66
+ return parts[2]
67
+ return None
68
+
69
+
70
+ def _github_run_url(env: dict[str, str]) -> Optional[str]:
71
+ server = env.get("GITHUB_SERVER_URL")
72
+ repo = env.get("GITHUB_REPOSITORY")
73
+ run = env.get("GITHUB_RUN_ID")
74
+ if server and repo and run:
75
+ return f"{server}/{repo}/actions/runs/{run}"
76
+ return None
77
+
78
+
79
+ def _pr_from_url(url: Optional[str]) -> Optional[str]:
80
+ if not url:
81
+ return None
82
+ return url.rstrip("/").split("/")[-1] or None
@@ -0,0 +1,93 @@
1
+ """`testrelic-playwright` CLI.
2
+
3
+ Two subcommands mirror the JS package:
4
+ testrelic-playwright merge a.json b.json -o merged.json
5
+ testrelic-playwright serve ./test-results/
6
+
7
+ Plus a `version` helper.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import http.server
13
+ import json
14
+ import socketserver
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import typer
19
+
20
+ from testrelic_playwright._version import __version__
21
+ from testrelic_playwright.merge import merge_reports
22
+
23
+ app = typer.Typer(
24
+ name="testrelic-playwright",
25
+ help="TestRelic Playwright analytics CLI",
26
+ add_completion=False,
27
+ )
28
+
29
+
30
+ @app.command()
31
+ def version() -> None:
32
+ """Print the package version."""
33
+ typer.echo(__version__)
34
+
35
+
36
+ @app.command()
37
+ def merge(
38
+ inputs: list[Path] = typer.Argument(..., help="Shard JSON reports to merge."),
39
+ output: Path = typer.Option(
40
+ "merged-report.json", "-o", "--output", help="Path to the merged JSON output."
41
+ ),
42
+ ) -> None:
43
+ """Combine multiple shard reports into a single JSON file."""
44
+ report = merge_reports(inputs, output=output)
45
+ typer.echo(f"[testrelic] merged {len(inputs)} reports -> {output}")
46
+ typer.echo(json.dumps(report.summary.model_dump(), indent=2))
47
+
48
+
49
+ @app.command()
50
+ def serve(
51
+ directory: Path = typer.Argument(..., help="Folder containing the HTML / JSON report."),
52
+ port: int = typer.Option(9323, "--port", help="Port to listen on (default 9323)."),
53
+ host: str = typer.Option("127.0.0.1", "--host", help="Address to bind to."),
54
+ ) -> None:
55
+ """Serve the HTML report locally (matches `npx testrelic serve`)."""
56
+ target = directory.resolve()
57
+ if not target.is_dir():
58
+ raise typer.BadParameter(f"{target} is not a directory")
59
+
60
+ handler = _make_handler(target)
61
+ with socketserver.TCPServer((host, port), handler) as server:
62
+ typer.echo(f"[testrelic] serving {target} at http://{host}:{port}/")
63
+ try:
64
+ server.serve_forever()
65
+ except KeyboardInterrupt:
66
+ typer.echo("\n[testrelic] stopped")
67
+
68
+
69
+ @app.command(name="drain")
70
+ def drain(
71
+ queue_dir: Optional[Path] = typer.Option(
72
+ None,
73
+ "--queue-dir",
74
+ help="Override the queue directory. Defaults to .testrelic/queue/.",
75
+ ),
76
+ ) -> None:
77
+ """Retry queued failed uploads."""
78
+ from testrelic_playwright.cloud.queue import drain_queue
79
+
80
+ counts = drain_queue(queue_dir)
81
+ typer.echo(json.dumps(counts, indent=2))
82
+
83
+
84
+ def _make_handler(root: Path) -> type[http.server.SimpleHTTPRequestHandler]:
85
+ class Handler(http.server.SimpleHTTPRequestHandler):
86
+ def __init__(self, *args: object, **kwargs: object) -> None:
87
+ super().__init__(*args, directory=str(root), **kwargs) # type: ignore[arg-type]
88
+
89
+ return Handler
90
+
91
+
92
+ if __name__ == "__main__":
93
+ app()
@@ -0,0 +1,13 @@
1
+ """Cloud upload package.
2
+
3
+ Public surface intentionally mirrors the JS `cloud-*` modules:
4
+ CloudUploader - synchronous client that POSTs the batch payload
5
+ drain_queue - replay failed uploads from disk
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from testrelic_playwright.cloud.client import CloudUploader
11
+ from testrelic_playwright.cloud.queue import drain_queue
12
+
13
+ __all__ = ["CloudUploader", "drain_queue"]
@@ -0,0 +1,52 @@
1
+ """HTTP client for uploading TestRelic reports to the cloud platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from testrelic_playwright._version import __version__
11
+ from testrelic_playwright.cloud.queue import enqueue
12
+ from testrelic_playwright.config import CloudConfig
13
+ from testrelic_playwright.types import TestRunReport
14
+
15
+ logger = logging.getLogger("testrelic")
16
+
17
+
18
+ class CloudUploader:
19
+ """Single-shot uploader. POSTs the full batch payload then closes the client."""
20
+
21
+ def __init__(self, config: CloudConfig) -> None:
22
+ self._config = config
23
+
24
+ def send(self, report: TestRunReport) -> dict[str, Any]:
25
+ if not self._config.api_key:
26
+ raise RuntimeError("Cloud upload attempted without an apiKey.")
27
+
28
+ url = f"{self._config.endpoint.rstrip('/')}/test-runs"
29
+ payload = report.model_dump(by_alias=True)
30
+ headers = {
31
+ "Authorization": f"Bearer {self._config.api_key}",
32
+ "User-Agent": f"testrelic-playwright/{__version__}",
33
+ "Accept": "application/json",
34
+ "Content-Type": "application/json",
35
+ }
36
+
37
+ timeout = self._config.timeout / 1000
38
+
39
+ try:
40
+ with httpx.Client(timeout=timeout) as client:
41
+ response = client.post(url, json=payload, headers=headers)
42
+ response.raise_for_status()
43
+ return response.json() if response.content else {}
44
+ except (httpx.HTTPError, ValueError) as exc:
45
+ queue_path = enqueue(
46
+ payload,
47
+ queue_dir=self._config.queue_directory,
48
+ )
49
+ logger.warning(
50
+ "[testrelic] cloud upload failed (%s); queued to %s", exc, queue_path
51
+ )
52
+ return {"queued": True, "path": str(queue_path)}
@@ -0,0 +1,83 @@
1
+ """Persist and replay failed uploads.
2
+
3
+ The on-disk format is `.testrelic/queue/<uuid>.json` — the same convention used
4
+ by the JS package, so a single queue directory can be drained by either
5
+ runtime.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ import httpx
18
+
19
+ from testrelic_playwright._version import __version__
20
+
21
+ logger = logging.getLogger("testrelic")
22
+ _DEFAULT_QUEUE = Path(".testrelic") / "queue"
23
+
24
+
25
+ def _resolve_queue_dir(directory: Optional[Path | str]) -> Path:
26
+ path = Path(directory) if directory else _DEFAULT_QUEUE
27
+ path.mkdir(parents=True, exist_ok=True)
28
+ return path
29
+
30
+
31
+ def enqueue(payload: dict[str, Any], *, queue_dir: Optional[Path | str] = None) -> Path:
32
+ """Write a payload to disk for later retry."""
33
+ target = _resolve_queue_dir(queue_dir)
34
+ entry = {
35
+ "id": str(uuid.uuid4()),
36
+ "payload": payload,
37
+ "timestamp": datetime.now(timezone.utc).isoformat(),
38
+ }
39
+ out = target / f"{entry['id']}.json"
40
+ out.write_text(json.dumps(entry, indent=2), encoding="utf-8")
41
+ return out
42
+
43
+
44
+ def drain_queue(queue_dir: Optional[Path | str] = None) -> dict[str, int]:
45
+ """Replay every queued payload to TestRelic. Returns {sent, failed, remaining}."""
46
+ import os
47
+
48
+ api_key = os.environ.get("TESTRELIC_API_KEY")
49
+ if not api_key:
50
+ raise RuntimeError("Set TESTRELIC_API_KEY before draining the queue.")
51
+ endpoint = os.environ.get(
52
+ "TESTRELIC_CLOUD_ENDPOINT", "https://platform.testrelic.ai/api/v1"
53
+ ).rstrip("/")
54
+
55
+ target = _resolve_queue_dir(queue_dir)
56
+ files = sorted(target.glob("*.json"))
57
+ if not files:
58
+ return {"sent": 0, "failed": 0, "remaining": 0}
59
+
60
+ headers = {
61
+ "Authorization": f"Bearer {api_key}",
62
+ "User-Agent": f"testrelic-playwright/{__version__}",
63
+ "Accept": "application/json",
64
+ "Content-Type": "application/json",
65
+ }
66
+
67
+ sent = failed = 0
68
+ with httpx.Client(timeout=30.0) as client:
69
+ for file in files:
70
+ try:
71
+ entry = json.loads(file.read_text(encoding="utf-8"))
72
+ response = client.post(
73
+ f"{endpoint}/test-runs", json=entry["payload"], headers=headers
74
+ )
75
+ response.raise_for_status()
76
+ file.unlink()
77
+ sent += 1
78
+ except Exception as exc: # noqa: BLE001
79
+ logger.warning("[testrelic] failed to replay %s: %s", file.name, exc)
80
+ failed += 1
81
+
82
+ remaining = len(list(target.glob("*.json")))
83
+ return {"sent": sent, "failed": failed, "remaining": remaining}
@@ -0,0 +1,29 @@
1
+ """Extract source-code snippets around a failure line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ def extract_snippet(file_path: str, line: int, context: int = 3) -> Optional[str]:
10
+ """Return `2*context+1` lines centred on `line` (1-indexed) or None."""
11
+ try:
12
+ path = Path(file_path)
13
+ if not path.is_file():
14
+ return None
15
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
16
+ except OSError:
17
+ return None
18
+
19
+ if line <= 0 or line > len(lines):
20
+ return None
21
+
22
+ start = max(1, line - context)
23
+ end = min(len(lines), line + context)
24
+ width = len(str(end))
25
+ rendered: list[str] = []
26
+ for idx in range(start, end + 1):
27
+ marker = ">" if idx == line else " "
28
+ rendered.append(f"{marker} {str(idx).rjust(width)} | {lines[idx - 1]}")
29
+ return "\n".join(rendered)