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.
Files changed (30) hide show
  1. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/CHANGELOG.md +7 -0
  2. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/PKG-INFO +20 -1
  3. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/README.md +19 -0
  4. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/__init__.py +19 -2
  5. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/allocation.py +55 -3
  6. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/client.py +75 -4
  7. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/pyproject.toml +1 -1
  8. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_allocation.py +155 -0
  9. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_client.py +284 -0
  10. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/uv.lock +1 -1
  11. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/.gitignore +0 -0
  12. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/__init__.py +0 -0
  13. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/_example_helpers.py +0 -0
  14. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/assets/hello-world.zip +0 -0
  15. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_android_mobile_screenshot.py +0 -0
  16. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_android_tv_screenshot.py +0 -0
  17. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_firetv_screenshot.py +0 -0
  18. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_ios_simulator_screenshot.py +0 -0
  19. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_roku_screenshot.py +0 -0
  20. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_roku_sideload_screenshot.py +0 -0
  21. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/examples/test_tvos_screenshot.py +0 -0
  22. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/appium.py +0 -0
  23. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/py.typed +0 -0
  24. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/pytest_plugin.py +0 -0
  25. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/gridfleet_testkit/sessions.py +0 -0
  26. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_appium.py +0 -0
  27. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_driver_agnostic_guard.py +0 -0
  28. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_package_metadata.py +0 -0
  29. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.4.0}/tests/test_pytest_plugin.py +0 -0
  30. {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.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 AllocatedDevice, hydrate_allocated_device, hydrate_allocated_device_from_driver
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.3.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
- config = client.get_device_config(connection_target) if fetch_config and connection_target else None
108
- live_capabilities = client.get_device_capabilities(device_id) if fetch_capabilities else None
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.raise_for_status()
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(self, run_id: str, *, worker_id: str) -> dict[str, Any]:
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:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gridfleet-testkit"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Supported pytest and run-orchestration helpers for GridFleet integrations"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.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",))
@@ -98,7 +98,7 @@ wheels = [
98
98
 
99
99
  [[package]]
100
100
  name = "gridfleet-testkit"
101
- version = "0.3.0"
101
+ version = "0.4.0"
102
102
  source = { editable = "." }
103
103
  dependencies = [
104
104
  { name = "httpx" },