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.
- testrelic_playwright/__init__.py +61 -0
- testrelic_playwright/_version.py +1 -0
- testrelic_playwright/api_request_tracker.py +140 -0
- testrelic_playwright/ci_detector.py +82 -0
- testrelic_playwright/cli.py +93 -0
- testrelic_playwright/cloud/__init__.py +13 -0
- testrelic_playwright/cloud/client.py +52 -0
- testrelic_playwright/cloud/queue.py +83 -0
- testrelic_playwright/code_extractor.py +29 -0
- testrelic_playwright/config.py +200 -0
- testrelic_playwright/html_report.py +127 -0
- testrelic_playwright/merge.py +82 -0
- testrelic_playwright/navigation_tracker.py +66 -0
- testrelic_playwright/plugin.py +262 -0
- testrelic_playwright/redaction.py +54 -0
- testrelic_playwright/reporter.py +171 -0
- testrelic_playwright/schema.py +14 -0
- testrelic_playwright/types.py +130 -0
- testrelic_playwright-0.1.0.dist-info/METADATA +252 -0
- testrelic_playwright-0.1.0.dist-info/RECORD +23 -0
- testrelic_playwright-0.1.0.dist-info/WHEEL +4 -0
- testrelic_playwright-0.1.0.dist-info/entry_points.txt +5 -0
- testrelic_playwright-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|