gridfleet-testkit 0.3.0__tar.gz → 0.4.0__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.
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/CHANGELOG.md +7 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/PKG-INFO +20 -1
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/README.md +19 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/__init__.py +19 -2
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/allocation.py +55 -3
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/client.py +75 -4
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/pyproject.toml +1 -1
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_allocation.py +155 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_client.py +284 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/uv.lock +1 -1
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/.gitignore +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/__init__.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/_example_helpers.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/assets/hello-world.zip +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_android_mobile_screenshot.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_android_tv_screenshot.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_firetv_screenshot.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_ios_simulator_screenshot.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_roku_screenshot.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_roku_sideload_screenshot.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_tvos_screenshot.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/appium.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/py.typed +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/pytest_plugin.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/sessions.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_appium.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_driver_agnostic_guard.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_package_metadata.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_pytest_plugin.py +0 -0
- {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_sessions.py +0 -0
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.4.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.3.0...gridfleet-testkit-v0.4.0) (2026-05-06)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **testkit:** wire ?include=config,capabilities through claim/reserve and hydrate inline ([#95](https://github.com/quidow/gridfleet/issues/95)) ([20ed20d](https://github.com/quidow/gridfleet/commit/20ed20d9ee362890923146e771ad8805b45e5bfa))
|
|
11
|
+
|
|
5
12
|
## [0.3.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.2.1...gridfleet-testkit-v0.3.0) (2026-05-05)
|
|
6
13
|
|
|
7
14
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gridfleet-testkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Supported pytest and run-orchestration helpers for GridFleet integrations
|
|
5
5
|
Project-URL: Homepage, https://github.com/quidow/gridfleet
|
|
6
6
|
Project-URL: Repository, https://github.com/quidow/gridfleet
|
|
@@ -280,6 +280,25 @@ assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
|
|
|
280
280
|
|
|
281
281
|
The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`.
|
|
282
282
|
|
|
283
|
+
### Reduced HTTP round-trips on claim
|
|
284
|
+
|
|
285
|
+
`gridfleet-testkit` 0.4.0 lets the manager inline the device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
client = GridFleetClient()
|
|
289
|
+
claim = client.claim_device(run_id, worker_id="w0", include=("config", "capabilities"))
|
|
290
|
+
allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
|
|
291
|
+
# zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Masking change**: inline `config` is always masked — sensitive values are replaced with `"********"`. Use `client.get_device_config(connection_target, reveal=True)` if you need raw secrets after the driver session is up. `allocated.config_is_masked` is `True` whenever the inline path was taken.
|
|
295
|
+
|
|
296
|
+
`reserve_devices` accepts `include=("config",)` only — `include=("capabilities",)` raises `ReserveCapabilitiesUnsupportedError` client-side because reserve-time capabilities are not yet device-bound. Pass `include=` on the per-worker `claim_device` call instead.
|
|
297
|
+
|
|
298
|
+
`include=` must be a sequence of strings (tuple or list) — order is preserved in the emitted query parameter. Passing a bare string like `include="config"` raises `TypeError` to avoid silently splitting the value into characters.
|
|
299
|
+
|
|
300
|
+
`hydrate_allocated_device` accepts claim responses only. For multi-device reservations, iterate `reserve_response["devices"]`, call `claim_device` per worker, and hydrate each claim response.
|
|
301
|
+
|
|
283
302
|
## Examples
|
|
284
303
|
|
|
285
304
|
Baseline screenshot examples:
|
|
@@ -246,6 +246,25 @@ assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
|
|
|
246
246
|
|
|
247
247
|
The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`.
|
|
248
248
|
|
|
249
|
+
### Reduced HTTP round-trips on claim
|
|
250
|
+
|
|
251
|
+
`gridfleet-testkit` 0.4.0 lets the manager inline the device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
client = GridFleetClient()
|
|
255
|
+
claim = client.claim_device(run_id, worker_id="w0", include=("config", "capabilities"))
|
|
256
|
+
allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
|
|
257
|
+
# zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Masking change**: inline `config` is always masked — sensitive values are replaced with `"********"`. Use `client.get_device_config(connection_target, reveal=True)` if you need raw secrets after the driver session is up. `allocated.config_is_masked` is `True` whenever the inline path was taken.
|
|
261
|
+
|
|
262
|
+
`reserve_devices` accepts `include=("config",)` only — `include=("capabilities",)` raises `ReserveCapabilitiesUnsupportedError` client-side because reserve-time capabilities are not yet device-bound. Pass `include=` on the per-worker `claim_device` call instead.
|
|
263
|
+
|
|
264
|
+
`include=` must be a sequence of strings (tuple or list) — order is preserved in the emitted query parameter. Passing a bare string like `include="config"` raises `TypeError` to avoid silently splitting the value into characters.
|
|
265
|
+
|
|
266
|
+
`hydrate_allocated_device` accepts claim responses only. For multi-device reservations, iterate `reserve_response["devices"]`, call `claim_device` per worker, and hydrate each claim response.
|
|
267
|
+
|
|
249
268
|
## Examples
|
|
250
269
|
|
|
251
270
|
Baseline screenshot examples:
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
"""Supported Python integration helpers for GridFleet.
|
|
2
2
|
|
|
3
|
+
**0.4.0 masking change**: when callers opt in to inline `config` via
|
|
4
|
+
`claim_device(include=("config",))`, the inline `config` payload is **always
|
|
5
|
+
masked** (sensitive values are replaced with `"********"`). Code that needs raw
|
|
6
|
+
secrets must continue to call `client.get_device_config(connection_target,
|
|
7
|
+
reveal=True)` explicitly. `AllocatedDevice.config_is_masked` is `True` whenever
|
|
8
|
+
the inline path was taken.
|
|
9
|
+
|
|
3
10
|
Environment variables read by the client:
|
|
4
11
|
|
|
5
12
|
- GRID_URL: Selenium Grid URL used by Appium helper defaults.
|
|
@@ -13,7 +20,12 @@ this package because run-state sharing is consumer policy.
|
|
|
13
20
|
|
|
14
21
|
from importlib.metadata import PackageNotFoundError, version
|
|
15
22
|
|
|
16
|
-
from .allocation import
|
|
23
|
+
from .allocation import (
|
|
24
|
+
AllocatedDevice,
|
|
25
|
+
UnavailableInclude,
|
|
26
|
+
hydrate_allocated_device,
|
|
27
|
+
hydrate_allocated_device_from_driver,
|
|
28
|
+
)
|
|
17
29
|
from .appium import (
|
|
18
30
|
build_appium_options,
|
|
19
31
|
create_appium_driver,
|
|
@@ -26,6 +38,8 @@ from .client import (
|
|
|
26
38
|
GridFleetClient,
|
|
27
39
|
HeartbeatThread,
|
|
28
40
|
NoClaimableDevicesError,
|
|
41
|
+
ReserveCapabilitiesUnsupportedError,
|
|
42
|
+
UnknownIncludeError,
|
|
29
43
|
register_run_cleanup,
|
|
30
44
|
)
|
|
31
45
|
from .sessions import build_error_session_payload
|
|
@@ -33,7 +47,7 @@ from .sessions import build_error_session_payload
|
|
|
33
47
|
try:
|
|
34
48
|
__version__ = version("gridfleet-testkit")
|
|
35
49
|
except PackageNotFoundError:
|
|
36
|
-
__version__ = "0.
|
|
50
|
+
__version__ = "0.4.0"
|
|
37
51
|
|
|
38
52
|
__all__ = [
|
|
39
53
|
"GRIDFLEET_API_URL",
|
|
@@ -42,6 +56,9 @@ __all__ = [
|
|
|
42
56
|
"GridFleetClient",
|
|
43
57
|
"HeartbeatThread",
|
|
44
58
|
"NoClaimableDevicesError",
|
|
59
|
+
"ReserveCapabilitiesUnsupportedError",
|
|
60
|
+
"UnavailableInclude",
|
|
61
|
+
"UnknownIncludeError",
|
|
45
62
|
"__version__",
|
|
46
63
|
"build_appium_options",
|
|
47
64
|
"build_error_session_payload",
|
|
@@ -9,6 +9,14 @@ if TYPE_CHECKING:
|
|
|
9
9
|
from .client import GridFleetClient
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class UnavailableInclude:
|
|
14
|
+
"""One include key the backend could not satisfy on this allocation."""
|
|
15
|
+
|
|
16
|
+
include: str
|
|
17
|
+
reason: str
|
|
18
|
+
|
|
19
|
+
|
|
12
20
|
@dataclass(frozen=True)
|
|
13
21
|
class AllocatedDevice:
|
|
14
22
|
"""Combined view of a claimed device, ready for driver creation."""
|
|
@@ -31,6 +39,8 @@ class AllocatedDevice:
|
|
|
31
39
|
claimed_at: str
|
|
32
40
|
config: dict[str, Any] | None
|
|
33
41
|
live_capabilities: dict[str, Any] | None
|
|
42
|
+
unavailable_includes: tuple[UnavailableInclude, ...] = ()
|
|
43
|
+
config_is_masked: bool = False
|
|
34
44
|
|
|
35
45
|
@property
|
|
36
46
|
def is_real_device(self) -> bool:
|
|
@@ -89,6 +99,21 @@ def _merge_device_detail(payload: dict[str, Any], detail: dict[str, Any]) -> dic
|
|
|
89
99
|
return merged
|
|
90
100
|
|
|
91
101
|
|
|
102
|
+
def _parse_unavailable_includes(payload: dict[str, Any]) -> tuple[UnavailableInclude, ...]:
|
|
103
|
+
raw = payload.get("unavailable_includes")
|
|
104
|
+
if not isinstance(raw, list):
|
|
105
|
+
return ()
|
|
106
|
+
parsed: list[UnavailableInclude] = []
|
|
107
|
+
for entry in raw:
|
|
108
|
+
if not isinstance(entry, dict):
|
|
109
|
+
continue
|
|
110
|
+
include = entry.get("include")
|
|
111
|
+
reason = entry.get("reason")
|
|
112
|
+
if isinstance(include, str) and include and isinstance(reason, str) and reason:
|
|
113
|
+
parsed.append(UnavailableInclude(include=include, reason=reason))
|
|
114
|
+
return tuple(parsed)
|
|
115
|
+
|
|
116
|
+
|
|
92
117
|
def hydrate_allocated_device(
|
|
93
118
|
claim_response: dict[str, Any],
|
|
94
119
|
*,
|
|
@@ -97,15 +122,40 @@ def hydrate_allocated_device(
|
|
|
97
122
|
fetch_config: bool = True,
|
|
98
123
|
fetch_capabilities: bool = False,
|
|
99
124
|
) -> AllocatedDevice:
|
|
100
|
-
"""Combine a claim response with optional static config and live capabilities.
|
|
125
|
+
"""Combine a claim response with optional static config and live capabilities.
|
|
126
|
+
|
|
127
|
+
Accepts a ``ClaimResponse`` payload from ``GridFleetClient.claim_device`` only.
|
|
128
|
+
Reserve responses (``RunCreateResponse.devices`` entries before any worker
|
|
129
|
+
has claimed) lack ``claimed_by`` / ``claimed_at`` and will raise
|
|
130
|
+
``ValueError``. Iterate ``reserve_response['devices']`` and call
|
|
131
|
+
``claim_device`` per worker before hydrating.
|
|
132
|
+
"""
|
|
101
133
|
payload = dict(claim_response)
|
|
102
134
|
device_id = _string_value(payload, "device_id")
|
|
103
135
|
if _needs_device_detail(payload):
|
|
104
136
|
payload = _merge_device_detail(payload, client.get_device(device_id))
|
|
105
137
|
|
|
138
|
+
unavailable_includes = _parse_unavailable_includes(payload)
|
|
139
|
+
unavailable_set = {entry.include for entry in unavailable_includes}
|
|
140
|
+
|
|
106
141
|
connection_target = _optional_string_value(payload, "connection_target")
|
|
107
|
-
|
|
108
|
-
|
|
142
|
+
inline_config = payload.get("config")
|
|
143
|
+
if isinstance(inline_config, dict):
|
|
144
|
+
config: dict[str, Any] | None = inline_config
|
|
145
|
+
config_is_masked = True
|
|
146
|
+
elif fetch_config and connection_target and "config" not in unavailable_set:
|
|
147
|
+
config = client.get_device_config(connection_target)
|
|
148
|
+
config_is_masked = False
|
|
149
|
+
else:
|
|
150
|
+
config = None
|
|
151
|
+
config_is_masked = False
|
|
152
|
+
inline_capabilities = payload.get("live_capabilities")
|
|
153
|
+
if isinstance(inline_capabilities, dict):
|
|
154
|
+
live_capabilities: dict[str, Any] | None = inline_capabilities
|
|
155
|
+
elif fetch_capabilities and "capabilities" not in unavailable_set:
|
|
156
|
+
live_capabilities = client.get_device_capabilities(device_id)
|
|
157
|
+
else:
|
|
158
|
+
live_capabilities = None
|
|
109
159
|
|
|
110
160
|
return AllocatedDevice(
|
|
111
161
|
run_id=run_id,
|
|
@@ -125,7 +175,9 @@ def hydrate_allocated_device(
|
|
|
125
175
|
claimed_by=_string_value(payload, "claimed_by"),
|
|
126
176
|
claimed_at=_string_value(payload, "claimed_at"),
|
|
127
177
|
config=config,
|
|
178
|
+
config_is_masked=config_is_masked,
|
|
128
179
|
live_capabilities=live_capabilities,
|
|
180
|
+
unavailable_includes=unavailable_includes,
|
|
129
181
|
)
|
|
130
182
|
|
|
131
183
|
|
|
@@ -9,7 +9,10 @@ import os
|
|
|
9
9
|
import signal
|
|
10
10
|
import threading
|
|
11
11
|
import time
|
|
12
|
-
from typing import Any, cast
|
|
12
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Sequence
|
|
13
16
|
|
|
14
17
|
import httpx
|
|
15
18
|
|
|
@@ -47,6 +50,21 @@ class NoClaimableDevicesError(RuntimeError):
|
|
|
47
50
|
super().__init__(message)
|
|
48
51
|
|
|
49
52
|
|
|
53
|
+
class UnknownIncludeError(ValueError):
|
|
54
|
+
"""Backend rejected one or more `?include=` keys."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, values: list[str]) -> None:
|
|
57
|
+
super().__init__(f"Backend rejected unknown include values: {values}")
|
|
58
|
+
self.values = values
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ReserveCapabilitiesUnsupportedError(ValueError):
|
|
62
|
+
"""`?include=capabilities` is not supported on reserve."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, message: str | None = None) -> None:
|
|
65
|
+
super().__init__(message or "include=capabilities is not supported on reserve; use include on claim_device")
|
|
66
|
+
|
|
67
|
+
|
|
50
68
|
def _raise_for_status(resp: Any, *, run_id: str) -> None:
|
|
51
69
|
if resp.status_code == 409:
|
|
52
70
|
try:
|
|
@@ -67,6 +85,21 @@ def _raise_for_status(resp: Any, *, run_id: str) -> None:
|
|
|
67
85
|
retry_after_sec=retry_after,
|
|
68
86
|
next_available_at=next_available_at if isinstance(next_available_at, str) else None,
|
|
69
87
|
)
|
|
88
|
+
if resp.status_code == 422:
|
|
89
|
+
try:
|
|
90
|
+
payload = resp.json()
|
|
91
|
+
except Exception:
|
|
92
|
+
payload = None
|
|
93
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
94
|
+
if isinstance(error, dict):
|
|
95
|
+
details = error.get("details")
|
|
96
|
+
if isinstance(details, dict):
|
|
97
|
+
code = details.get("code")
|
|
98
|
+
if code == "unknown_include":
|
|
99
|
+
values = details.get("values")
|
|
100
|
+
raise UnknownIncludeError(values if isinstance(values, list) else [])
|
|
101
|
+
if code == "reserve_capabilities_unsupported":
|
|
102
|
+
raise ReserveCapabilitiesUnsupportedError(str(error.get("message") or ""))
|
|
70
103
|
resp.raise_for_status()
|
|
71
104
|
|
|
72
105
|
|
|
@@ -91,6 +124,26 @@ def _query_params(values: dict[str, Any]) -> list[tuple[str, str | int | float |
|
|
|
91
124
|
return params
|
|
92
125
|
|
|
93
126
|
|
|
127
|
+
def _normalize_include(include: Sequence[str] | None) -> tuple[str, ...] | None:
|
|
128
|
+
if include is None:
|
|
129
|
+
return None
|
|
130
|
+
if isinstance(include, (str, bytes)):
|
|
131
|
+
raise TypeError(
|
|
132
|
+
"include must be a sequence of strings, not a string itself "
|
|
133
|
+
"(e.g. include=('config',), not include='config')"
|
|
134
|
+
)
|
|
135
|
+
return tuple(include)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _include_param(include: tuple[str, ...] | None) -> list[tuple[str, str | int | float | bool | None]] | None:
|
|
139
|
+
if include is None:
|
|
140
|
+
return None
|
|
141
|
+
values = [v for v in include if v]
|
|
142
|
+
if not values:
|
|
143
|
+
return None
|
|
144
|
+
return [("include", ",".join(values))]
|
|
145
|
+
|
|
146
|
+
|
|
94
147
|
def _raise_or_warn(operation: str, suppress_errors: bool, exc: Exception) -> None:
|
|
95
148
|
if not suppress_errors:
|
|
96
149
|
raise exc
|
|
@@ -254,8 +307,15 @@ class GridFleetClient:
|
|
|
254
307
|
ttl_minutes: int = 60,
|
|
255
308
|
heartbeat_timeout_sec: int = 120,
|
|
256
309
|
created_by: str | None = None,
|
|
310
|
+
*,
|
|
311
|
+
include: Sequence[str] | None = None,
|
|
257
312
|
) -> dict[str, Any]:
|
|
258
313
|
"""Reserve devices for a test run and return the manager response."""
|
|
314
|
+
include_tuple = _normalize_include(include)
|
|
315
|
+
if include_tuple is not None and "capabilities" in include_tuple:
|
|
316
|
+
raise ReserveCapabilitiesUnsupportedError(
|
|
317
|
+
"include='capabilities' is not supported on reserve; use include on claim_device instead"
|
|
318
|
+
)
|
|
259
319
|
resp = httpx.post(
|
|
260
320
|
f"{self.base_url}/runs",
|
|
261
321
|
json={
|
|
@@ -265,10 +325,11 @@ class GridFleetClient:
|
|
|
265
325
|
"heartbeat_timeout_sec": heartbeat_timeout_sec,
|
|
266
326
|
"created_by": created_by,
|
|
267
327
|
},
|
|
328
|
+
params=_include_param(include_tuple),
|
|
268
329
|
timeout=30,
|
|
269
330
|
auth=self._auth,
|
|
270
331
|
)
|
|
271
|
-
resp
|
|
332
|
+
_raise_for_status(resp, run_id="")
|
|
272
333
|
return cast("dict[str, Any]", resp.json())
|
|
273
334
|
|
|
274
335
|
def signal_ready(self, run_id: str) -> None:
|
|
@@ -294,10 +355,18 @@ class GridFleetClient:
|
|
|
294
355
|
resp.raise_for_status()
|
|
295
356
|
return cast("dict[str, Any]", resp.json())
|
|
296
357
|
|
|
297
|
-
def claim_device(
|
|
358
|
+
def claim_device(
|
|
359
|
+
self,
|
|
360
|
+
run_id: str,
|
|
361
|
+
*,
|
|
362
|
+
worker_id: str,
|
|
363
|
+
include: Sequence[str] | None = None,
|
|
364
|
+
) -> dict[str, Any]:
|
|
365
|
+
include_tuple = _normalize_include(include)
|
|
298
366
|
resp = httpx.post(
|
|
299
367
|
f"{self.base_url}/runs/{run_id}/claim",
|
|
300
368
|
json={"worker_id": worker_id},
|
|
369
|
+
params=_include_param(include_tuple),
|
|
301
370
|
timeout=10,
|
|
302
371
|
auth=self._auth,
|
|
303
372
|
)
|
|
@@ -355,11 +424,13 @@ class GridFleetClient:
|
|
|
355
424
|
*,
|
|
356
425
|
worker_id: str,
|
|
357
426
|
max_wait_sec: int = 300,
|
|
427
|
+
include: Sequence[str] | None = None,
|
|
358
428
|
) -> dict[str, Any]:
|
|
359
429
|
deadline = time.monotonic() + max_wait_sec
|
|
430
|
+
include_tuple = _normalize_include(include)
|
|
360
431
|
while True:
|
|
361
432
|
try:
|
|
362
|
-
return self.claim_device(run_id, worker_id=worker_id)
|
|
433
|
+
return self.claim_device(run_id, worker_id=worker_id, include=include_tuple)
|
|
363
434
|
except NoClaimableDevicesError as exc:
|
|
364
435
|
remaining = deadline - time.monotonic()
|
|
365
436
|
if remaining <= 0:
|
|
@@ -6,6 +6,7 @@ import pytest
|
|
|
6
6
|
|
|
7
7
|
from gridfleet_testkit.allocation import (
|
|
8
8
|
AllocatedDevice,
|
|
9
|
+
UnavailableInclude,
|
|
9
10
|
hydrate_allocated_device,
|
|
10
11
|
hydrate_allocated_device_from_driver,
|
|
11
12
|
)
|
|
@@ -163,6 +164,160 @@ def test_allocated_device_properties_prefer_stable_sources() -> None:
|
|
|
163
164
|
assert allocated.platform_name == "Android"
|
|
164
165
|
|
|
165
166
|
|
|
167
|
+
def test_allocated_device_defaults_unavailable_includes_and_config_is_masked() -> None:
|
|
168
|
+
allocated = AllocatedDevice(
|
|
169
|
+
run_id="run-1",
|
|
170
|
+
device_id="dev-1",
|
|
171
|
+
identity_value="SERIAL123",
|
|
172
|
+
name="Pixel 6",
|
|
173
|
+
pack_id="appium-uiautomator2",
|
|
174
|
+
platform_id="android_mobile",
|
|
175
|
+
platform_label="Android",
|
|
176
|
+
os_version="14",
|
|
177
|
+
connection_target="SERIAL123",
|
|
178
|
+
host_ip="192.168.1.10",
|
|
179
|
+
device_type="real_device",
|
|
180
|
+
connection_type="usb",
|
|
181
|
+
manufacturer="Google",
|
|
182
|
+
model="Pixel 6",
|
|
183
|
+
claimed_by="gw0",
|
|
184
|
+
claimed_at="2026-05-05T10:00:00Z",
|
|
185
|
+
config=None,
|
|
186
|
+
live_capabilities=None,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
assert allocated.unavailable_includes == ()
|
|
190
|
+
assert isinstance(allocated.unavailable_includes, tuple)
|
|
191
|
+
assert all(isinstance(u, UnavailableInclude) for u in allocated.unavailable_includes)
|
|
192
|
+
assert allocated.config_is_masked is False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_hydrate_allocated_device_uses_inline_config_and_skips_get() -> None:
|
|
196
|
+
client = FakeClient()
|
|
197
|
+
payload = claim_payload(config={"ip": "10.0.0.8", "username": "operator", "password": "********"})
|
|
198
|
+
|
|
199
|
+
allocated = hydrate_allocated_device(payload, run_id="run-1", client=client)
|
|
200
|
+
|
|
201
|
+
assert allocated.config == {"ip": "10.0.0.8", "username": "operator", "password": "********"}
|
|
202
|
+
assert allocated.config_is_masked is True
|
|
203
|
+
assert client.config_calls == []
|
|
204
|
+
assert client.device_calls == []
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_hydrate_allocated_device_falls_back_to_get_device_config_when_inline_absent() -> None:
|
|
208
|
+
client = FakeClient()
|
|
209
|
+
|
|
210
|
+
allocated = hydrate_allocated_device(claim_payload(), run_id="run-1", client=client)
|
|
211
|
+
|
|
212
|
+
assert allocated.config == {"ip": "10.0.0.8", "username": "operator"}
|
|
213
|
+
assert allocated.config_is_masked is False
|
|
214
|
+
assert client.config_calls == [("SERIAL123", True)]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_hydrate_allocated_device_uses_inline_live_capabilities_and_skips_get() -> None:
|
|
218
|
+
client = FakeClient()
|
|
219
|
+
payload = claim_payload(live_capabilities={"appium:udid": "INLINE-CAP", "appium:deviceIP": "10.0.0.99"})
|
|
220
|
+
|
|
221
|
+
allocated = hydrate_allocated_device(payload, run_id="run-1", client=client, fetch_config=False)
|
|
222
|
+
|
|
223
|
+
assert allocated.live_capabilities == {"appium:udid": "INLINE-CAP", "appium:deviceIP": "10.0.0.99"}
|
|
224
|
+
assert client.capability_calls == []
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_hydrate_allocated_device_uses_inline_live_capabilities_even_when_fetch_capabilities_false() -> None:
|
|
228
|
+
client = FakeClient()
|
|
229
|
+
payload = claim_payload(live_capabilities={"appium:udid": "INLINE-CAP"})
|
|
230
|
+
|
|
231
|
+
allocated = hydrate_allocated_device(
|
|
232
|
+
payload,
|
|
233
|
+
run_id="run-1",
|
|
234
|
+
client=client,
|
|
235
|
+
fetch_config=False,
|
|
236
|
+
fetch_capabilities=False,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
assert allocated.live_capabilities == {"appium:udid": "INLINE-CAP"}
|
|
240
|
+
assert client.capability_calls == []
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_hydrate_allocated_device_surfaces_unavailable_includes() -> None:
|
|
244
|
+
client = FakeClient()
|
|
245
|
+
payload = claim_payload(
|
|
246
|
+
unavailable_includes=[{"include": "capabilities", "reason": "device_offline"}],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
allocated = hydrate_allocated_device(payload, run_id="run-1", client=client, fetch_config=False)
|
|
250
|
+
|
|
251
|
+
assert allocated.unavailable_includes == (UnavailableInclude(include="capabilities", reason="device_offline"),)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_hydrate_allocated_device_unavailable_includes_defaults_to_empty_tuple() -> None:
|
|
255
|
+
client = FakeClient()
|
|
256
|
+
|
|
257
|
+
allocated = hydrate_allocated_device(claim_payload(), run_id="run-1", client=client, fetch_config=False)
|
|
258
|
+
|
|
259
|
+
assert allocated.unavailable_includes == ()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_hydrate_allocated_device_skips_malformed_unavailable_include_entries() -> None:
|
|
263
|
+
client = FakeClient()
|
|
264
|
+
payload = claim_payload(
|
|
265
|
+
unavailable_includes=[
|
|
266
|
+
{"include": "capabilities", "reason": "device_offline"},
|
|
267
|
+
{"include": "config"},
|
|
268
|
+
"not-a-dict",
|
|
269
|
+
{"reason": "missing_include_key"},
|
|
270
|
+
],
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
allocated = hydrate_allocated_device(payload, run_id="run-1", client=client, fetch_config=False)
|
|
274
|
+
|
|
275
|
+
assert allocated.unavailable_includes == (UnavailableInclude(include="capabilities", reason="device_offline"),)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_hydrate_allocated_device_skips_config_fetch_when_marked_unavailable() -> None:
|
|
279
|
+
client = FakeClient()
|
|
280
|
+
payload = claim_payload(
|
|
281
|
+
unavailable_includes=[{"include": "config", "reason": "device_offline"}],
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
allocated = hydrate_allocated_device(payload, run_id="run-1", client=client)
|
|
285
|
+
|
|
286
|
+
assert allocated.config is None
|
|
287
|
+
assert allocated.config_is_masked is False
|
|
288
|
+
assert client.config_calls == []
|
|
289
|
+
assert allocated.unavailable_includes == (UnavailableInclude(include="config", reason="device_offline"),)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_hydrate_allocated_device_skips_capabilities_fetch_when_marked_unavailable() -> None:
|
|
293
|
+
client = FakeClient()
|
|
294
|
+
payload = claim_payload(
|
|
295
|
+
unavailable_includes=[{"include": "capabilities", "reason": "device_offline"}],
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
allocated = hydrate_allocated_device(
|
|
299
|
+
payload,
|
|
300
|
+
run_id="run-1",
|
|
301
|
+
client=client,
|
|
302
|
+
fetch_config=False,
|
|
303
|
+
fetch_capabilities=True,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
assert allocated.live_capabilities is None
|
|
307
|
+
assert client.capability_calls == []
|
|
308
|
+
assert allocated.unavailable_includes == (UnavailableInclude(include="capabilities", reason="device_offline"),)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_hydrate_allocated_device_rejects_reserve_shaped_payload_without_claim_metadata() -> None:
|
|
312
|
+
client = FakeClient()
|
|
313
|
+
reserve_shaped = claim_payload()
|
|
314
|
+
del reserve_shaped["claimed_by"]
|
|
315
|
+
del reserve_shaped["claimed_at"]
|
|
316
|
+
|
|
317
|
+
with pytest.raises(ValueError, match="missing claimed_by"):
|
|
318
|
+
hydrate_allocated_device(reserve_shaped, run_id="run-1", client=client, fetch_config=False)
|
|
319
|
+
|
|
320
|
+
|
|
166
321
|
def test_hydrate_allocated_device_from_driver_returns_new_frozen_instance() -> None:
|
|
167
322
|
client = FakeClient()
|
|
168
323
|
allocated = hydrate_allocated_device(claim_payload(), run_id="run-1", client=client, fetch_config=False)
|
|
@@ -10,7 +10,10 @@ from gridfleet_testkit.client import (
|
|
|
10
10
|
GridFleetClient,
|
|
11
11
|
HeartbeatThread,
|
|
12
12
|
NoClaimableDevicesError,
|
|
13
|
+
ReserveCapabilitiesUnsupportedError,
|
|
14
|
+
UnknownIncludeError,
|
|
13
15
|
_default_auth,
|
|
16
|
+
_raise_for_status,
|
|
14
17
|
register_run_cleanup,
|
|
15
18
|
)
|
|
16
19
|
|
|
@@ -211,6 +214,7 @@ def test_reserve_devices_posts_expected_payload(monkeypatch):
|
|
|
211
214
|
*,
|
|
212
215
|
json: dict[str, Any],
|
|
213
216
|
timeout: int,
|
|
217
|
+
params: list[tuple[str, str]] | None = None,
|
|
214
218
|
auth: Any = None,
|
|
215
219
|
) -> DummyResponse:
|
|
216
220
|
recorded["url"] = url
|
|
@@ -251,6 +255,7 @@ def test_reserve_devices_all_available_payload(monkeypatch):
|
|
|
251
255
|
*,
|
|
252
256
|
json: dict[str, Any],
|
|
253
257
|
timeout: int,
|
|
258
|
+
params: list[tuple[str, str]] | None = None,
|
|
254
259
|
auth: Any = None,
|
|
255
260
|
) -> DummyResponse:
|
|
256
261
|
recorded["url"] = url
|
|
@@ -332,6 +337,7 @@ def test_claim_device_calls_api(monkeypatch):
|
|
|
332
337
|
*,
|
|
333
338
|
json: dict[str, Any],
|
|
334
339
|
timeout: int,
|
|
340
|
+
params: list[tuple[str, str]] | None = None,
|
|
335
341
|
auth: Any = None,
|
|
336
342
|
) -> DummyResponse:
|
|
337
343
|
recorded["url"] = url
|
|
@@ -359,6 +365,7 @@ def test_claim_device_raises_no_claimable_devices_with_retry_metadata(monkeypatc
|
|
|
359
365
|
*,
|
|
360
366
|
json: dict[str, Any],
|
|
361
367
|
timeout: int,
|
|
368
|
+
params: list[tuple[str, str]] | None = None,
|
|
362
369
|
auth: Any = None,
|
|
363
370
|
) -> DummyResponse:
|
|
364
371
|
return DummyResponse(
|
|
@@ -420,6 +427,7 @@ def test_claim_device_with_retry_sleeps_and_retries(monkeypatch):
|
|
|
420
427
|
*,
|
|
421
428
|
json: dict[str, Any],
|
|
422
429
|
timeout: int,
|
|
430
|
+
params: list[tuple[str, str]] | None = None,
|
|
423
431
|
auth: Any = None,
|
|
424
432
|
) -> DummyResponse:
|
|
425
433
|
calls.append(url)
|
|
@@ -908,6 +916,7 @@ def test_client_threads_default_auth_into_requests(monkeypatch):
|
|
|
908
916
|
*,
|
|
909
917
|
json: dict[str, Any],
|
|
910
918
|
timeout: int,
|
|
919
|
+
params: list[tuple[str, str]] | None = None,
|
|
911
920
|
auth: Any = None,
|
|
912
921
|
) -> DummyResponse:
|
|
913
922
|
captured["auth"] = auth
|
|
@@ -932,6 +941,7 @@ def test_client_explicit_auth_overrides_env_default(monkeypatch):
|
|
|
932
941
|
*,
|
|
933
942
|
json: dict[str, Any],
|
|
934
943
|
timeout: int,
|
|
944
|
+
params: list[tuple[str, str]] | None = None,
|
|
935
945
|
auth: Any = None,
|
|
936
946
|
) -> DummyResponse:
|
|
937
947
|
captured["auth"] = auth
|
|
@@ -969,3 +979,277 @@ def test_heartbeat_thread_passes_auth(monkeypatch):
|
|
|
969
979
|
|
|
970
980
|
assert captured["url"] == "http://manager/api/runs/run-x/heartbeat"
|
|
971
981
|
assert captured["auth"] is explicit
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def test_raise_for_status_maps_unknown_include_422_to_typed_exception():
|
|
985
|
+
resp = DummyResponse(
|
|
986
|
+
{
|
|
987
|
+
"error": {
|
|
988
|
+
"code": "INVALID_INCLUDE",
|
|
989
|
+
"message": "Unknown include values",
|
|
990
|
+
"details": {"code": "unknown_include", "values": ["garbage"]},
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
status_code=422,
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
with pytest.raises(UnknownIncludeError) as exc_info:
|
|
997
|
+
_raise_for_status(resp, run_id="run-1")
|
|
998
|
+
|
|
999
|
+
assert exc_info.value.values == ["garbage"]
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def test_raise_for_status_maps_reserve_capabilities_unsupported_422_to_typed_exception():
|
|
1003
|
+
resp = DummyResponse(
|
|
1004
|
+
{
|
|
1005
|
+
"error": {
|
|
1006
|
+
"code": "INVALID_INCLUDE",
|
|
1007
|
+
"message": "include=capabilities not supported on reserve",
|
|
1008
|
+
"details": {"code": "reserve_capabilities_unsupported"},
|
|
1009
|
+
}
|
|
1010
|
+
},
|
|
1011
|
+
status_code=422,
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
with pytest.raises(ReserveCapabilitiesUnsupportedError):
|
|
1015
|
+
_raise_for_status(resp, run_id="")
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def test_raise_for_status_passes_through_unrelated_422():
|
|
1019
|
+
resp = DummyResponse({"detail": "validation"}, status_code=422)
|
|
1020
|
+
|
|
1021
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
1022
|
+
_raise_for_status(resp, run_id="")
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def test_claim_device_threads_include_query_param(monkeypatch):
|
|
1026
|
+
captured: dict[str, Any] = {}
|
|
1027
|
+
|
|
1028
|
+
def fake_post(
|
|
1029
|
+
url: str,
|
|
1030
|
+
*,
|
|
1031
|
+
json: dict[str, Any],
|
|
1032
|
+
timeout: int,
|
|
1033
|
+
params: list[tuple[str, str]] | None = None,
|
|
1034
|
+
auth: Any = None,
|
|
1035
|
+
) -> DummyResponse:
|
|
1036
|
+
captured["url"] = url
|
|
1037
|
+
captured["params"] = params
|
|
1038
|
+
return DummyResponse({"device_id": "dev-1", "claimed_by": "gw0", "claimed_at": "2026-05-05T00:00:00Z"})
|
|
1039
|
+
|
|
1040
|
+
monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
|
|
1041
|
+
|
|
1042
|
+
client = GridFleetClient("http://manager/api")
|
|
1043
|
+
client.claim_device("run-1", worker_id="gw0", include=("config", "capabilities"))
|
|
1044
|
+
|
|
1045
|
+
assert captured["params"] == [("include", "config,capabilities")]
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def test_claim_device_omits_include_param_when_unset(monkeypatch):
|
|
1049
|
+
captured: dict[str, Any] = {}
|
|
1050
|
+
|
|
1051
|
+
def fake_post(
|
|
1052
|
+
url: str,
|
|
1053
|
+
*,
|
|
1054
|
+
json: dict[str, Any],
|
|
1055
|
+
timeout: int,
|
|
1056
|
+
params: list[tuple[str, str]] | None = None,
|
|
1057
|
+
auth: Any = None,
|
|
1058
|
+
) -> DummyResponse:
|
|
1059
|
+
captured["params"] = params
|
|
1060
|
+
return DummyResponse({"device_id": "dev-1", "claimed_by": "gw0", "claimed_at": "2026-05-05T00:00:00Z"})
|
|
1061
|
+
|
|
1062
|
+
monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
|
|
1063
|
+
|
|
1064
|
+
client = GridFleetClient("http://manager/api")
|
|
1065
|
+
client.claim_device("run-1", worker_id="gw0")
|
|
1066
|
+
|
|
1067
|
+
assert captured["params"] is None or captured["params"] == []
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def test_claim_device_accepts_arbitrary_iterable_for_include(monkeypatch):
|
|
1071
|
+
captured: dict[str, Any] = {}
|
|
1072
|
+
|
|
1073
|
+
def fake_post(
|
|
1074
|
+
url: str,
|
|
1075
|
+
*,
|
|
1076
|
+
json: dict[str, Any],
|
|
1077
|
+
timeout: int,
|
|
1078
|
+
params: list[tuple[str, str]] | None = None,
|
|
1079
|
+
auth: Any = None,
|
|
1080
|
+
) -> DummyResponse:
|
|
1081
|
+
captured["params"] = params
|
|
1082
|
+
return DummyResponse({"device_id": "dev-1", "claimed_by": "gw0", "claimed_at": "2026-05-05T00:00:00Z"})
|
|
1083
|
+
|
|
1084
|
+
monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
|
|
1085
|
+
|
|
1086
|
+
client = GridFleetClient("http://manager/api")
|
|
1087
|
+
client.claim_device("run-1", worker_id="gw0", include=["config"])
|
|
1088
|
+
|
|
1089
|
+
assert captured["params"] == [("include", "config")]
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def test_claim_device_rejects_string_include_to_avoid_character_split():
|
|
1093
|
+
client = GridFleetClient("http://manager/api")
|
|
1094
|
+
with pytest.raises(TypeError, match="must be a sequence of strings"):
|
|
1095
|
+
client.claim_device("run-1", worker_id="gw0", include="config")
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def test_claim_device_rejects_bytes_include():
|
|
1099
|
+
client = GridFleetClient("http://manager/api")
|
|
1100
|
+
with pytest.raises(TypeError, match="must be a sequence of strings"):
|
|
1101
|
+
client.claim_device("run-1", worker_id="gw0", include=b"config")
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def test_claim_device_raises_unknown_include_on_422(monkeypatch):
|
|
1105
|
+
def fake_post(
|
|
1106
|
+
url: str,
|
|
1107
|
+
*,
|
|
1108
|
+
json: dict[str, Any],
|
|
1109
|
+
timeout: int,
|
|
1110
|
+
params: list[tuple[str, str]] | None = None,
|
|
1111
|
+
auth: Any = None,
|
|
1112
|
+
) -> DummyResponse:
|
|
1113
|
+
return DummyResponse(
|
|
1114
|
+
{
|
|
1115
|
+
"error": {
|
|
1116
|
+
"code": "INVALID_INCLUDE",
|
|
1117
|
+
"message": "Unknown include values",
|
|
1118
|
+
"details": {"code": "unknown_include", "values": ["garbage"]},
|
|
1119
|
+
}
|
|
1120
|
+
},
|
|
1121
|
+
status_code=422,
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
|
|
1125
|
+
|
|
1126
|
+
client = GridFleetClient("http://manager/api")
|
|
1127
|
+
with pytest.raises(UnknownIncludeError) as exc_info:
|
|
1128
|
+
client.claim_device("run-1", worker_id="gw0", include=("garbage",))
|
|
1129
|
+
|
|
1130
|
+
assert exc_info.value.values == ["garbage"]
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def test_claim_device_with_retry_forwards_include_on_every_attempt(monkeypatch):
|
|
1134
|
+
seen: list[list[tuple[str, str]] | None] = []
|
|
1135
|
+
responses = iter(
|
|
1136
|
+
[
|
|
1137
|
+
DummyResponse(
|
|
1138
|
+
{
|
|
1139
|
+
"error": {
|
|
1140
|
+
"code": "CONFLICT",
|
|
1141
|
+
"message": "No unclaimed devices available in this run",
|
|
1142
|
+
"details": {"error": "no_claimable_devices", "retry_after_sec": 1, "next_available_at": None},
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
status_code=409,
|
|
1146
|
+
),
|
|
1147
|
+
DummyResponse({"device_id": "dev-1", "claimed_by": "gw0", "claimed_at": "2026-05-05T00:00:00Z"}),
|
|
1148
|
+
]
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
def fake_post(
|
|
1152
|
+
url: str,
|
|
1153
|
+
*,
|
|
1154
|
+
json: dict[str, Any],
|
|
1155
|
+
timeout: int,
|
|
1156
|
+
params: list[tuple[str, str]] | None = None,
|
|
1157
|
+
auth: Any = None,
|
|
1158
|
+
) -> DummyResponse:
|
|
1159
|
+
seen.append(params)
|
|
1160
|
+
return next(responses)
|
|
1161
|
+
|
|
1162
|
+
monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
|
|
1163
|
+
monkeypatch.setattr("gridfleet_testkit.client.time.sleep", lambda _seconds: None)
|
|
1164
|
+
|
|
1165
|
+
client = GridFleetClient("http://manager/api")
|
|
1166
|
+
client.claim_device_with_retry("run-1", worker_id="gw0", max_wait_sec=5, include=("config",))
|
|
1167
|
+
|
|
1168
|
+
assert seen == [
|
|
1169
|
+
[("include", "config")],
|
|
1170
|
+
[("include", "config")],
|
|
1171
|
+
]
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def test_reserve_devices_threads_include_query_param(monkeypatch):
|
|
1175
|
+
captured: dict[str, Any] = {}
|
|
1176
|
+
|
|
1177
|
+
def fake_post(
|
|
1178
|
+
url: str,
|
|
1179
|
+
*,
|
|
1180
|
+
json: dict[str, Any],
|
|
1181
|
+
timeout: int,
|
|
1182
|
+
params: list[tuple[str, str]] | None = None,
|
|
1183
|
+
auth: Any = None,
|
|
1184
|
+
) -> DummyResponse:
|
|
1185
|
+
captured["url"] = url
|
|
1186
|
+
captured["params"] = params
|
|
1187
|
+
return DummyResponse({"id": "run-1", "devices": []})
|
|
1188
|
+
|
|
1189
|
+
monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
|
|
1190
|
+
|
|
1191
|
+
client = GridFleetClient("http://manager/api")
|
|
1192
|
+
client.reserve_devices(name="r", requirements=[], include=("config",))
|
|
1193
|
+
|
|
1194
|
+
assert captured["params"] == [("include", "config")]
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def test_reserve_devices_rejects_capabilities_include_before_http_call(monkeypatch):
|
|
1198
|
+
called: list[str] = []
|
|
1199
|
+
|
|
1200
|
+
def fake_post(*args: Any, **kwargs: Any) -> DummyResponse:
|
|
1201
|
+
called.append("post")
|
|
1202
|
+
return DummyResponse({})
|
|
1203
|
+
|
|
1204
|
+
monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
|
|
1205
|
+
|
|
1206
|
+
client = GridFleetClient("http://manager/api")
|
|
1207
|
+
with pytest.raises(ReserveCapabilitiesUnsupportedError):
|
|
1208
|
+
client.reserve_devices(name="r", requirements=[], include=("config", "capabilities"))
|
|
1209
|
+
|
|
1210
|
+
assert called == []
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def test_reserve_devices_rejects_string_include_before_http_call(monkeypatch):
|
|
1214
|
+
called: list[str] = []
|
|
1215
|
+
|
|
1216
|
+
def fake_post(*args: Any, **kwargs: Any) -> DummyResponse:
|
|
1217
|
+
called.append("post")
|
|
1218
|
+
return DummyResponse({})
|
|
1219
|
+
|
|
1220
|
+
monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
|
|
1221
|
+
|
|
1222
|
+
client = GridFleetClient("http://manager/api")
|
|
1223
|
+
with pytest.raises(TypeError, match="must be a sequence of strings"):
|
|
1224
|
+
client.reserve_devices(name="r", requirements=[], include="capabilities")
|
|
1225
|
+
|
|
1226
|
+
assert called == []
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def test_reserve_devices_raises_reserve_capabilities_unsupported_on_422(monkeypatch):
|
|
1230
|
+
def fake_post(
|
|
1231
|
+
url: str,
|
|
1232
|
+
*,
|
|
1233
|
+
json: dict[str, Any],
|
|
1234
|
+
timeout: int,
|
|
1235
|
+
params: list[tuple[str, str]] | None = None,
|
|
1236
|
+
auth: Any = None,
|
|
1237
|
+
) -> DummyResponse:
|
|
1238
|
+
return DummyResponse(
|
|
1239
|
+
{
|
|
1240
|
+
"error": {
|
|
1241
|
+
"code": "INVALID_INCLUDE",
|
|
1242
|
+
"message": "include=capabilities not supported on reserve",
|
|
1243
|
+
"details": {"code": "reserve_capabilities_unsupported"},
|
|
1244
|
+
}
|
|
1245
|
+
},
|
|
1246
|
+
status_code=422,
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
|
|
1250
|
+
|
|
1251
|
+
client = GridFleetClient("http://manager/api")
|
|
1252
|
+
# Use include=("config",) so the client-side guard does not fire.
|
|
1253
|
+
# The 422 then exercises the defense-in-depth path through _raise_for_status.
|
|
1254
|
+
with pytest.raises(ReserveCapabilitiesUnsupportedError):
|
|
1255
|
+
client.reserve_devices(name="r", requirements=[], include=("config",))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_android_mobile_screenshot.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_ios_simulator_screenshot.py
RENAMED
|
File without changes
|
|
File without changes
|
{gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_roku_sideload_screenshot.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|