Skip to content

Commit 4c23cef

Browse files
Add robust session recording response parsing
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 6c2809e commit 4c23cef

4 files changed

Lines changed: 216 additions & 2 deletions

File tree

hyperbrowser/client/managers/async_manager/session.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import warnings
55
from hyperbrowser.exceptions import HyperbrowserError
66
from ...file_utils import ensure_existing_file_path
7+
from ..session_utils import parse_session_recordings_response_data
78
from ....models.session import (
89
BasicResponse,
910
CreateSessionParams,
@@ -89,7 +90,7 @@ async def get_recording(self, id: str) -> List[SessionRecording]:
8990
response = await self._client.transport.get(
9091
self._client._build_url(f"/session/{id}/recording"), None, True
9192
)
92-
return [SessionRecording(**recording) for recording in response.data]
93+
return parse_session_recordings_response_data(response.data)
9394

9495
async def get_recording_url(self, id: str) -> GetSessionRecordingUrlResponse:
9596
response = await self._client.transport.get(
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from collections.abc import Mapping
2+
from typing import Any, List
3+
4+
from hyperbrowser.exceptions import HyperbrowserError
5+
from hyperbrowser.models.session import SessionRecording
6+
7+
8+
def parse_session_recordings_response_data(response_data: Any) -> List[SessionRecording]:
9+
if not isinstance(response_data, list):
10+
raise HyperbrowserError(
11+
"Expected session recording response to be a list of objects"
12+
)
13+
parsed_recordings: List[SessionRecording] = []
14+
for index, recording in enumerate(response_data):
15+
if not isinstance(recording, Mapping):
16+
raise HyperbrowserError(
17+
"Expected session recording object at index "
18+
f"{index} but got {type(recording).__name__}"
19+
)
20+
try:
21+
parsed_recordings.append(SessionRecording(**dict(recording)))
22+
except HyperbrowserError:
23+
raise
24+
except Exception as exc:
25+
raise HyperbrowserError(
26+
f"Failed to parse session recording at index {index}",
27+
original_error=exc,
28+
) from exc
29+
return parsed_recordings

hyperbrowser/client/managers/sync_manager/session.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import warnings
55
from hyperbrowser.exceptions import HyperbrowserError
66
from ...file_utils import ensure_existing_file_path
7+
from ..session_utils import parse_session_recordings_response_data
78
from ....models.session import (
89
BasicResponse,
910
CreateSessionParams,
@@ -83,7 +84,7 @@ def get_recording(self, id: str) -> List[SessionRecording]:
8384
response = self._client.transport.get(
8485
self._client._build_url(f"/session/{id}/recording"), None, True
8586
)
86-
return [SessionRecording(**recording) for recording in response.data]
87+
return parse_session_recordings_response_data(response.data)
8788

8889
def get_recording_url(self, id: str) -> GetSessionRecordingUrlResponse:
8990
response = self._client.transport.get(
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import asyncio
2+
from types import MappingProxyType
3+
4+
import pytest
5+
6+
from hyperbrowser.client.managers.async_manager.session import (
7+
SessionManager as AsyncSessionManager,
8+
)
9+
from hyperbrowser.client.managers.session_utils import (
10+
parse_session_recordings_response_data,
11+
)
12+
from hyperbrowser.client.managers.sync_manager.session import (
13+
SessionManager as SyncSessionManager,
14+
)
15+
from hyperbrowser.exceptions import HyperbrowserError
16+
17+
18+
class _FakeResponse:
19+
def __init__(self, data):
20+
self.data = data
21+
22+
23+
class _SyncTransport:
24+
def __init__(self, response_data):
25+
self._response_data = response_data
26+
27+
def get(self, url, params=None, follow_redirects=False):
28+
_ = params
29+
_ = follow_redirects
30+
assert url.endswith("/session/session_123/recording")
31+
return _FakeResponse(self._response_data)
32+
33+
34+
class _AsyncTransport:
35+
def __init__(self, response_data):
36+
self._response_data = response_data
37+
38+
async def get(self, url, params=None, follow_redirects=False):
39+
_ = params
40+
_ = follow_redirects
41+
assert url.endswith("/session/session_123/recording")
42+
return _FakeResponse(self._response_data)
43+
44+
45+
class _FakeClient:
46+
def __init__(self, transport):
47+
self.transport = transport
48+
49+
def _build_url(self, path: str) -> str:
50+
return f"https://api.hyperbrowser.ai/api{path}"
51+
52+
53+
def test_parse_session_recordings_response_data_parses_list_payloads():
54+
recordings = parse_session_recordings_response_data(
55+
[
56+
{
57+
"type": 1,
58+
"data": {"event": "click"},
59+
"timestamp": 123,
60+
}
61+
]
62+
)
63+
64+
assert len(recordings) == 1
65+
assert recordings[0].type == 1
66+
assert recordings[0].timestamp == 123
67+
68+
69+
def test_parse_session_recordings_response_data_accepts_mapping_proxy_items():
70+
recordings = parse_session_recordings_response_data(
71+
[
72+
MappingProxyType(
73+
{
74+
"type": 1,
75+
"data": {"event": "scroll"},
76+
"timestamp": 321,
77+
}
78+
)
79+
]
80+
)
81+
82+
assert len(recordings) == 1
83+
assert recordings[0].timestamp == 321
84+
85+
86+
def test_parse_session_recordings_response_data_rejects_non_list_payloads():
87+
with pytest.raises(
88+
HyperbrowserError,
89+
match="Expected session recording response to be a list of objects",
90+
):
91+
parse_session_recordings_response_data({"type": 1}) # type: ignore[arg-type]
92+
93+
94+
def test_parse_session_recordings_response_data_rejects_non_mapping_items():
95+
with pytest.raises(
96+
HyperbrowserError,
97+
match="Expected session recording object at index 0 but got str",
98+
):
99+
parse_session_recordings_response_data(["invalid-item"])
100+
101+
102+
def test_parse_session_recordings_response_data_wraps_invalid_items():
103+
with pytest.raises(
104+
HyperbrowserError, match="Failed to parse session recording at index 0"
105+
) as exc_info:
106+
parse_session_recordings_response_data(
107+
[
108+
{
109+
"type": 1,
110+
# missing required fields
111+
}
112+
]
113+
)
114+
115+
assert exc_info.value.original_error is not None
116+
117+
118+
def test_sync_session_manager_get_recording_uses_recording_parser():
119+
manager = SyncSessionManager(
120+
_FakeClient(
121+
_SyncTransport(
122+
[
123+
{
124+
"type": 1,
125+
"data": {"event": "click"},
126+
"timestamp": 123,
127+
}
128+
]
129+
)
130+
)
131+
)
132+
133+
recordings = manager.get_recording("session_123")
134+
135+
assert len(recordings) == 1
136+
assert recordings[0].timestamp == 123
137+
138+
139+
def test_async_session_manager_get_recording_uses_recording_parser():
140+
manager = AsyncSessionManager(
141+
_FakeClient(
142+
_AsyncTransport(
143+
[
144+
{
145+
"type": 1,
146+
"data": {"event": "click"},
147+
"timestamp": 123,
148+
}
149+
]
150+
)
151+
)
152+
)
153+
154+
async def run():
155+
return await manager.get_recording("session_123")
156+
157+
recordings = asyncio.run(run())
158+
159+
assert len(recordings) == 1
160+
assert recordings[0].timestamp == 123
161+
162+
163+
def test_sync_session_manager_get_recording_rejects_invalid_payload_shapes():
164+
manager = SyncSessionManager(_FakeClient(_SyncTransport({"bad": "payload"})))
165+
166+
with pytest.raises(
167+
HyperbrowserError,
168+
match="Expected session recording response to be a list of objects",
169+
):
170+
manager.get_recording("session_123")
171+
172+
173+
def test_async_session_manager_get_recording_rejects_invalid_payload_shapes():
174+
manager = AsyncSessionManager(_FakeClient(_AsyncTransport({"bad": "payload"})))
175+
176+
async def run():
177+
with pytest.raises(
178+
HyperbrowserError,
179+
match="Expected session recording response to be a list of objects",
180+
):
181+
await manager.get_recording("session_123")
182+
183+
asyncio.run(run())

0 commit comments

Comments
 (0)