gridfleet-testkit 0.5.0__tar.gz → 0.7.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 (35) hide show
  1. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/CHANGELOG.md +40 -0
  2. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/PKG-INFO +1 -1
  3. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/__init__.py +4 -8
  4. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/allocation.py +4 -15
  5. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/appium.py +1 -0
  6. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/client.py +67 -184
  7. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/pytest_plugin.py +11 -1
  8. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/sessions.py +16 -2
  9. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/pyproject.toml +1 -1
  10. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_allocation.py +20 -38
  11. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_appium.py +1 -0
  12. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_client.py +123 -515
  13. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_package_metadata.py +0 -2
  14. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_pytest_plugin.py +1 -0
  15. gridfleet_testkit-0.7.0/tests/test_pytest_plugin_grid_run_id_injection.py +56 -0
  16. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_sessions.py +1 -1
  17. gridfleet_testkit-0.7.0/tests/test_sessions_resolve_device_handle.py +25 -0
  18. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/uv.lock +4 -4
  19. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/.gitignore +0 -0
  20. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/README.md +0 -0
  21. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/__init__.py +0 -0
  22. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/_example_helpers.py +0 -0
  23. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/assets/hello-world.zip +0 -0
  24. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_android_mobile_screenshot.py +0 -0
  25. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_android_tv_screenshot.py +0 -0
  26. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_firetv_screenshot.py +0 -0
  27. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_ios_simulator_screenshot.py +0 -0
  28. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_roku_screenshot.py +0 -0
  29. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_roku_sideload_screenshot.py +0 -0
  30. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_tvos_screenshot.py +0 -0
  31. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/py.typed +0 -0
  32. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_client_test_data.py +0 -0
  33. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_docs_contract.py +0 -0
  34. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_driver_agnostic_guard.py +0 -0
  35. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_pytest_plugin_test_data.py +0 -0
@@ -2,6 +2,46 @@
2
2
 
3
3
  All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
4
4
 
5
+ ## [0.7.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.6.0...gridfleet-testkit-v0.7.0) (2026-05-11)
6
+
7
+
8
+ ### ⚠ BREAKING CHANGES
9
+
10
+ * **testkit:** AllocatedDevice hydration now accepts device handles, not claim payloads.
11
+ * **testkit:** GridFleetClient claim/release helpers and NoClaimableDevicesError are removed.
12
+
13
+ ### Features
14
+
15
+ * **testkit:** drop claim release client api ([8d1f295](https://github.com/quidow/gridfleet/commit/8d1f29504e6d640ce9d85c70594a515868045be8))
16
+ * **testkit:** inject grid run id capability ([b4ae38c](https://github.com/quidow/gridfleet/commit/b4ae38ce9969ae325bffdb9716ba7d6c52a699ac))
17
+ * **testkit:** resolve device handle by connection target ([22b4299](https://github.com/quidow/gridfleet/commit/22b4299d6bc4302503a2c2b6f17018ceebc03084))
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **agent:** release adapter-owned doctor refactor ([#165](https://github.com/quidow/gridfleet/issues/165)) ([f3ae257](https://github.com/quidow/gridfleet/commit/f3ae25787e2c8ef926312f11d2313c6513f8bfa9))
23
+
24
+
25
+ ### Dependencies
26
+
27
+ * **deps:** bump urllib3 from 2.6.3 to 2.7.0 in /testkit ([#186](https://github.com/quidow/gridfleet/issues/186)) ([dd7a1df](https://github.com/quidow/gridfleet/commit/dd7a1df8fb29ae76f618abab947db2027471b536))
28
+
29
+
30
+ ### Code Refactoring
31
+
32
+ * **testkit:** drop claim response allocation metadata ([f0eec3e](https://github.com/quidow/gridfleet/commit/f0eec3e9c9f804439241ddbbb56b196bc467effd))
33
+
34
+ ## [0.6.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.5.0...gridfleet-testkit-v0.6.0) (2026-05-10)
35
+
36
+
37
+ ### ⚠ BREAKING CHANGES
38
+
39
+ * **backend:** clients sending {drain: true|false} to /api/devices/ {id}/maintenance, /api/devices/bulk/enter-maintenance, or the group bulk equivalent must drop the field. The enter-maintenance behaviour is unchanged from drain=false (always stop the node).
40
+
41
+ ### Features
42
+
43
+ * **backend:** device state model drift fixes (D1-D6) ([#144](https://github.com/quidow/gridfleet/issues/144)) ([09556fd](https://github.com/quidow/gridfleet/commit/09556fdac8ddb458f1655f9001f25240443062fb))
44
+
5
45
  ## [0.5.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.4.0...gridfleet-testkit-v0.5.0) (2026-05-08)
6
46
 
7
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridfleet-testkit
3
- Version: 0.5.0
3
+ Version: 0.7.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
@@ -2,8 +2,7 @@
2
2
 
3
3
  `device_config` values returned by the manager are verbatim; the testkit no
4
4
  longer distinguishes between masked and revealed payloads. Code that wants
5
- the live Appium-side config can use either inline `config` from
6
- `claim_device(include=("config",))` or `client.get_device_config(connection_target)`.
5
+ the live Appium-side config can use `client.get_device_config(connection_target)`.
7
6
 
8
7
  Environment variables read by the client:
9
8
 
@@ -32,31 +31,27 @@ from .appium import (
32
31
  get_device_test_data_for_driver,
33
32
  )
34
33
  from .client import (
35
- CooldownResult,
36
34
  GridFleetClient,
37
35
  HeartbeatThread,
38
- NoClaimableDevicesError,
39
36
  ReserveCapabilitiesUnsupportedError,
40
37
  UnknownIncludeError,
41
38
  _default_api_url,
42
39
  _default_grid_url,
43
40
  register_run_cleanup,
44
41
  )
45
- from .sessions import build_error_session_payload
42
+ from .sessions import build_error_session_payload, resolve_device_handle_from_driver
46
43
 
47
44
  try:
48
45
  __version__ = version("gridfleet-testkit")
49
46
  except PackageNotFoundError:
50
- __version__ = "0.5.0"
47
+ __version__ = "0.7.0"
51
48
 
52
49
  __all__ = [
53
50
  "GRIDFLEET_API_URL",
54
51
  "GRID_URL",
55
52
  "AllocatedDevice",
56
- "CooldownResult",
57
53
  "GridFleetClient",
58
54
  "HeartbeatThread",
59
- "NoClaimableDevicesError",
60
55
  "ReserveCapabilitiesUnsupportedError",
61
56
  "UnavailableInclude",
62
57
  "UnknownIncludeError",
@@ -70,6 +65,7 @@ __all__ = [
70
65
  "hydrate_allocated_device",
71
66
  "hydrate_allocated_device_from_driver",
72
67
  "register_run_cleanup",
68
+ "resolve_device_handle_from_driver",
73
69
  ]
74
70
 
75
71
 
@@ -19,7 +19,7 @@ class UnavailableInclude:
19
19
 
20
20
  @dataclass(frozen=True)
21
21
  class AllocatedDevice:
22
- """Combined view of a claimed device, ready for driver creation."""
22
+ """Combined view of an allocated device, ready for driver creation."""
23
23
 
24
24
  run_id: str
25
25
  device_id: str
@@ -35,8 +35,6 @@ class AllocatedDevice:
35
35
  connection_type: str
36
36
  manufacturer: str | None
37
37
  model: str | None
38
- claimed_by: str
39
- claimed_at: str
40
38
  config: dict[str, Any] | None
41
39
  live_capabilities: dict[str, Any] | None
42
40
  test_data: dict[str, Any] | None = None
@@ -115,7 +113,7 @@ def _parse_unavailable_includes(payload: dict[str, Any]) -> tuple[UnavailableInc
115
113
 
116
114
 
117
115
  def hydrate_allocated_device(
118
- claim_response: dict[str, Any],
116
+ device_handle: dict[str, Any],
119
117
  *,
120
118
  run_id: str,
121
119
  client: GridFleetClient,
@@ -123,15 +121,8 @@ def hydrate_allocated_device(
123
121
  fetch_capabilities: bool = False,
124
122
  fetch_test_data: bool = False,
125
123
  ) -> AllocatedDevice:
126
- """Combine a claim response with optional static config and live capabilities.
127
-
128
- Accepts a ``ClaimResponse`` payload from ``GridFleetClient.claim_device`` only.
129
- Reserve responses (``RunCreateResponse.devices`` entries before any worker
130
- has claimed) lack ``claimed_by`` / ``claimed_at`` and will raise
131
- ``ValueError``. Iterate ``reserve_response['devices']`` and call
132
- ``claim_device`` per worker before hydrating.
133
- """
134
- payload = dict(claim_response)
124
+ """Combine a device handle with optional static config and live capabilities."""
125
+ payload = dict(device_handle)
135
126
  device_id = _string_value(payload, "device_id")
136
127
  if _needs_device_detail(payload):
137
128
  payload = _merge_device_detail(payload, client.get_device(device_id))
@@ -178,8 +169,6 @@ def hydrate_allocated_device(
178
169
  connection_type=_string_value(payload, "connection_type"),
179
170
  manufacturer=_optional_string_value(payload, "manufacturer"),
180
171
  model=_optional_string_value(payload, "model"),
181
- claimed_by=_string_value(payload, "claimed_by"),
182
- claimed_at=_string_value(payload, "claimed_at"),
183
172
  config=config,
184
173
  live_capabilities=live_capabilities,
185
174
  test_data=test_data,
@@ -98,6 +98,7 @@ def build_appium_options(
98
98
  from appium.options.common import AppiumOptions # noqa: PLC0415
99
99
 
100
100
  params = dict(capabilities or {})
101
+ params.setdefault("gridfleet:run_id", os.environ.get("GRIDFLEET_RUN_ID", "free"))
101
102
  explicit_platform_name = params.get("platformName")
102
103
  if explicit_platform_name is not None and (pack_id is not None or platform_id is not None):
103
104
  raise ValueError("Use either pack_id/platform_id or the raw platformName capability, not both.")
@@ -7,10 +7,9 @@ import logging
7
7
  import os
8
8
  import signal
9
9
  import threading
10
- import time
11
10
  from collections.abc import Callable
12
- from datetime import datetime, timezone
13
- from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
11
+ from typing import TYPE_CHECKING, Any, Literal, cast
12
+ from urllib.parse import quote
14
13
 
15
14
  if TYPE_CHECKING:
16
15
  from collections.abc import Sequence
@@ -40,31 +39,6 @@ def __getattr__(name: str) -> str:
40
39
  raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
41
40
 
42
41
 
43
- class CooldownSetResult(TypedDict):
44
- """Response shape when a cooldown is applied without reaching the escalation threshold."""
45
-
46
- status: Literal["cooldown_set"]
47
- reservation: dict[str, Any]
48
- device_operational_state: str
49
- device_hold: str | None
50
- retry_after_sec: int
51
- excluded_until: str
52
-
53
-
54
- class CooldownEscalatedResult(TypedDict):
55
- """Response shape when the cooldown count reaches the threshold and the device is escalated to maintenance."""
56
-
57
- status: Literal["maintenance_escalated"]
58
- reservation: dict[str, Any]
59
- device_operational_state: str
60
- device_hold: str | None
61
- cooldown_count: int
62
- threshold: int
63
-
64
-
65
- CooldownResult = CooldownSetResult | CooldownEscalatedResult
66
-
67
-
68
42
  def _default_auth() -> httpx.BasicAuth | None:
69
43
  """Build httpx Basic auth from env vars, or return None when unset."""
70
44
  username = os.getenv("GRIDFLEET_TESTKIT_USERNAME")
@@ -74,23 +48,6 @@ def _default_auth() -> httpx.BasicAuth | None:
74
48
  return httpx.BasicAuth(username, password)
75
49
 
76
50
 
77
- class NoClaimableDevicesError(RuntimeError):
78
- """Raised when the manager reports that no run devices are claimable yet."""
79
-
80
- def __init__(
81
- self,
82
- message: str,
83
- *,
84
- retry_after_sec: int,
85
- run_id: str = "",
86
- next_available_at: str | None = None,
87
- ) -> None:
88
- self.run_id = run_id
89
- self.retry_after_sec = retry_after_sec
90
- self.next_available_at = next_available_at
91
- super().__init__(message)
92
-
93
-
94
51
  class UnknownIncludeError(ValueError):
95
52
  """Backend rejected one or more `?include=` keys."""
96
53
 
@@ -103,29 +60,11 @@ class ReserveCapabilitiesUnsupportedError(ValueError):
103
60
  """`?include=capabilities` is not supported on reserve."""
104
61
 
105
62
  def __init__(self, message: str | None = None) -> None:
106
- super().__init__(message or "include=capabilities is not supported on reserve; use include on claim_device")
63
+ super().__init__(message or "include=capabilities is not supported on reserve")
107
64
 
108
65
 
109
66
  def _raise_for_status(resp: Any, *, run_id: str) -> None:
110
- if resp.status_code == 409:
111
- try:
112
- payload = resp.json()
113
- except Exception:
114
- payload = None
115
- error = payload.get("error") if isinstance(payload, dict) else None
116
- if isinstance(error, dict):
117
- details = error.get("details")
118
- if isinstance(details, dict) and details.get("error") == "no_claimable_devices":
119
- retry_after = details.get("retry_after_sec")
120
- if not isinstance(retry_after, int):
121
- retry_after = 5
122
- next_available_at = details.get("next_available_at")
123
- raise NoClaimableDevicesError(
124
- str(error.get("message") or "No unclaimed devices available in this run"),
125
- run_id=run_id,
126
- retry_after_sec=retry_after,
127
- next_available_at=next_available_at if isinstance(next_available_at, str) else None,
128
- )
67
+ del run_id
129
68
  if resp.status_code == 422:
130
69
  try:
131
70
  payload = resp.json()
@@ -144,15 +83,6 @@ def _raise_for_status(resp: Any, *, run_id: str) -> None:
144
83
  resp.raise_for_status()
145
84
 
146
85
 
147
- def _is_safe_release_conflict(resp: Any) -> bool:
148
- try:
149
- payload = resp.json()
150
- except Exception:
151
- return False
152
- detail = payload.get("detail") if isinstance(payload, dict) else None
153
- return isinstance(detail, str) and "is not claimed" in detail.lower()
154
-
155
-
156
86
  def _query_params(values: dict[str, Any]) -> list[tuple[str, str | int | float | bool | None]]:
157
87
  params: list[tuple[str, str | int | float | bool | None]] = []
158
88
  for key, value in values.items():
@@ -191,20 +121,6 @@ def _raise_or_warn(operation: str, suppress_errors: bool, exc: Exception) -> Non
191
121
  logger.warning("Failed to %s with GridFleet: %s", operation, exc)
192
122
 
193
123
 
194
- def _parse_next_available_delay(next_available_at: str | None) -> int | None:
195
- if not next_available_at:
196
- return None
197
- value = next_available_at.replace("Z", "+00:00")
198
- try:
199
- parsed = datetime.fromisoformat(value)
200
- except ValueError:
201
- return None
202
- if parsed.tzinfo is None:
203
- parsed = parsed.replace(tzinfo=timezone.utc)
204
- delay = int(parsed.timestamp() - time.time())
205
- return max(1, delay) if delay > 0 else 1
206
-
207
-
208
124
  class HeartbeatThread(threading.Thread):
209
125
  """Background thread that sends periodic heartbeat pings for an active test run."""
210
126
 
@@ -313,19 +229,20 @@ class GridFleetClient:
313
229
  resp.raise_for_status()
314
230
  return cast("dict[str, Any]", resp.json())
315
231
 
316
- def resolve_device_id_by_connection_target(self, connection_target: str) -> str:
317
- """Look up the backend device id for a runtime connection target."""
232
+ def get_device_by_connection_target(self, target: str) -> dict[str, Any]:
233
+ """Fetch one device detail row by runtime connection target."""
318
234
  resp = httpx.get(
319
- f"{self.base_url}/devices",
320
- params={"connection_target": connection_target},
235
+ f"{self.base_url}/devices/by-connection-target/{quote(target, safe='')}",
321
236
  timeout=10,
322
237
  auth=self._auth,
323
238
  )
324
239
  resp.raise_for_status()
325
- devices = cast("list[dict[str, Any]]", resp.json())
326
- if not devices:
327
- raise ValueError(f"No device found with connection target: {connection_target}")
328
- return cast("str", devices[0]["id"])
240
+ return cast("dict[str, Any]", resp.json())
241
+
242
+ def resolve_device_id_by_connection_target(self, connection_target: str) -> str:
243
+ """Look up the backend device id for a runtime connection target."""
244
+ device = self.get_device_by_connection_target(connection_target)
245
+ return cast("str", device["id"])
329
246
 
330
247
  def get_device_config(self, connection_target: str) -> dict[str, Any]:
331
248
  """Fetch device config by looking up the current runtime connection target."""
@@ -403,9 +320,7 @@ class GridFleetClient:
403
320
  """Reserve devices for a test run and return the manager response."""
404
321
  include_tuple = _normalize_include(include)
405
322
  if include_tuple is not None and "capabilities" in include_tuple:
406
- raise ReserveCapabilitiesUnsupportedError(
407
- "include='capabilities' is not supported on reserve; use include on claim_device instead"
408
- )
323
+ raise ReserveCapabilitiesUnsupportedError("include='capabilities' is not supported on reserve")
409
324
  resp = httpx.post(
410
325
  f"{self.base_url}/runs",
411
326
  json={
@@ -449,89 +364,6 @@ class GridFleetClient:
449
364
  resp.raise_for_status()
450
365
  return cast("dict[str, Any]", resp.json())
451
366
 
452
- def claim_device(
453
- self,
454
- run_id: str,
455
- *,
456
- worker_id: str,
457
- include: Sequence[str] | None = None,
458
- ) -> dict[str, Any]:
459
- include_tuple = _normalize_include(include)
460
- resp = httpx.post(
461
- f"{self.base_url}/runs/{run_id}/claim",
462
- json={"worker_id": worker_id},
463
- params=_include_param(include_tuple),
464
- timeout=10,
465
- auth=self._auth,
466
- )
467
- _raise_for_status(resp, run_id=run_id)
468
- return cast("dict[str, Any]", resp.json())
469
-
470
- def release_device(self, run_id: str, *, device_id: str, worker_id: str) -> None:
471
- resp = httpx.post(
472
- f"{self.base_url}/runs/{run_id}/release",
473
- json={"device_id": device_id, "worker_id": worker_id},
474
- timeout=10,
475
- auth=self._auth,
476
- )
477
- resp.raise_for_status()
478
-
479
- def release_device_safe(self, run_id: str, *, device_id: str, worker_id: str) -> bool:
480
- """Release a claim while tolerating already-terminal run/device states.
481
-
482
- Returns True when the manager accepts the release, False when the run is
483
- gone or the claim is explicitly already unclaimed. Other HTTP errors,
484
- including wrong-worker conflicts, still raise.
485
- """
486
- resp = httpx.post(
487
- f"{self.base_url}/runs/{run_id}/release",
488
- json={"device_id": device_id, "worker_id": worker_id},
489
- timeout=10,
490
- auth=self._auth,
491
- )
492
- if resp.status_code == 404 or (resp.status_code == 409 and _is_safe_release_conflict(resp)):
493
- return False
494
- resp.raise_for_status()
495
- return True
496
-
497
- def release_device_with_cooldown(
498
- self,
499
- run_id: str,
500
- *,
501
- device_id: str,
502
- worker_id: str,
503
- reason: str,
504
- ttl_seconds: int,
505
- ) -> CooldownResult:
506
- resp = httpx.post(
507
- f"{self.base_url}/runs/{run_id}/devices/{device_id}/release-with-cooldown",
508
- json={"worker_id": worker_id, "reason": reason, "ttl_seconds": ttl_seconds},
509
- timeout=10,
510
- auth=self._auth,
511
- )
512
- resp.raise_for_status()
513
- return cast("CooldownResult", resp.json())
514
-
515
- def claim_device_with_retry(
516
- self,
517
- run_id: str,
518
- *,
519
- worker_id: str,
520
- max_wait_sec: int = 300,
521
- include: Sequence[str] | None = None,
522
- ) -> dict[str, Any]:
523
- deadline = time.monotonic() + max_wait_sec
524
- include_tuple = _normalize_include(include)
525
- while True:
526
- try:
527
- return self.claim_device(run_id, worker_id=worker_id, include=include_tuple)
528
- except NoClaimableDevicesError as exc:
529
- remaining = deadline - time.monotonic()
530
- if remaining <= 0:
531
- raise
532
- sleep_for = _parse_next_available_delay(exc.next_available_at) or exc.retry_after_sec
533
- time.sleep(min(sleep_for, max(1, int(remaining))))
534
-
535
367
  def report_preparation_failure(
536
368
  self,
537
369
  run_id: str,
@@ -600,6 +432,27 @@ class GridFleetClient:
600
432
  return None
601
433
  return cast("dict[str, Any]", resp.json())
602
434
 
435
+ def notify_session_finished(
436
+ self,
437
+ session_id: str,
438
+ *,
439
+ suppress_errors: bool = True,
440
+ ) -> None:
441
+ """Tell the manager the WebDriver session has ended.
442
+
443
+ Idempotent on the backend — repeated calls are a no-op once the
444
+ Session row is marked ended.
445
+ """
446
+ try:
447
+ resp = httpx.post(
448
+ f"{self.base_url}/sessions/{session_id}/finished",
449
+ timeout=5,
450
+ auth=self._auth,
451
+ )
452
+ resp.raise_for_status()
453
+ except (httpx.HTTPError, TypeError, ValueError) as exc:
454
+ _raise_or_warn("notify session finished", suppress_errors, exc)
455
+
603
456
  def update_session_status(
604
457
  self,
605
458
  session_id: str,
@@ -629,7 +482,12 @@ class GridFleetClient:
629
482
  run_id: str | None = None,
630
483
  suppress_errors: bool = True,
631
484
  ) -> dict[str, Any] | None:
632
- """Extract session metadata from an Appium driver and register it."""
485
+ """Extract session metadata from an Appium driver and register it.
486
+
487
+ Also wraps ``driver.quit`` so that the first call after a successful
488
+ registration fires :meth:`notify_session_finished` automatically.
489
+ Errors from notify are suppressed — they must never break the caller.
490
+ """
633
491
  capabilities = getattr(driver, "capabilities", {})
634
492
  if not isinstance(capabilities, dict):
635
493
  capabilities = {}
@@ -638,7 +496,7 @@ class GridFleetClient:
638
496
  raise RuntimeError("Created Appium driver did not expose a session ID")
639
497
  device_id = capabilities.get("appium:gridfleet:deviceId") or capabilities.get("gridfleet:deviceId")
640
498
  connection_target = capabilities.get("appium:udid") or capabilities.get("appium:deviceName")
641
- return self.register_session(
499
+ result = self.register_session(
642
500
  session_id=session_id,
643
501
  test_name=test_name,
644
502
  device_id=device_id if isinstance(device_id, str) and device_id else None,
@@ -647,6 +505,31 @@ class GridFleetClient:
647
505
  run_id=run_id,
648
506
  suppress_errors=suppress_errors,
649
507
  )
508
+ self._wrap_quit_for_notify(driver, session_id)
509
+ return result
510
+
511
+ def _wrap_quit_for_notify(self, driver: Any, session_id: str) -> None:
512
+ """Replace ``driver.quit`` with a wrapper that also notifies the manager.
513
+
514
+ The notify fires at most once per registration: after the first quit
515
+ succeeds, subsequent quit() calls run the underlying quit but do
516
+ NOT post to /finished again. The underlying quit still runs every
517
+ call.
518
+
519
+ Raises AttributeError if the driver lacks a quit method.
520
+ """
521
+ original_quit = driver.quit
522
+ notified: dict[str, bool] = {"done": False}
523
+
524
+ def wrapped_quit(*args: Any, **kwargs: Any) -> Any:
525
+ try:
526
+ return original_quit(*args, **kwargs)
527
+ finally:
528
+ if not notified["done"]:
529
+ notified["done"] = True
530
+ self.notify_session_finished(session_id, suppress_errors=True)
531
+
532
+ driver.quit = wrapped_quit
650
533
 
651
534
  def complete_run(self, run_id: str) -> dict[str, Any]:
652
535
  resp = httpx.post(
@@ -13,7 +13,7 @@ from .appium import (
13
13
  get_device_test_data_for_driver,
14
14
  )
15
15
  from .client import GridFleetClient, _default_grid_url
16
- from .sessions import build_error_session_payload
16
+ from .sessions import build_error_session_payload, resolve_device_handle_from_driver
17
17
 
18
18
  if TYPE_CHECKING:
19
19
  from collections.abc import Generator
@@ -147,6 +147,16 @@ def device_test_data(appium_driver: Any, gridfleet_client: GridFleetClient) -> d
147
147
  raise RuntimeError("unreachable: pytest.skip did not raise") from exc
148
148
 
149
149
 
150
+ @pytest.fixture
151
+ def device_handle(appium_driver: Any, gridfleet_client: GridFleetClient) -> dict[str, Any]:
152
+ """Fetch the canonical manager device row after Grid assigns a runtime target."""
153
+ try:
154
+ return resolve_device_handle_from_driver(appium_driver, client=gridfleet_client)
155
+ except RuntimeError as exc:
156
+ pytest.skip(str(exc))
157
+ raise RuntimeError("unreachable: pytest.skip did not raise") from exc
158
+
159
+
150
160
  def _gridfleet_worker_id(request: pytest.FixtureRequest) -> str:
151
161
  """Return pytest-xdist worker id, or controller for non-worker processes."""
152
162
  workerinput = getattr(request.config, "workerinput", None)
@@ -2,9 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any
5
+ from typing import TYPE_CHECKING, Any
6
6
 
7
- __all__ = ["build_error_session_payload"]
7
+ if TYPE_CHECKING:
8
+ from .client import GridFleetClient
9
+
10
+ __all__ = ["build_error_session_payload", "resolve_device_handle_from_driver"]
8
11
 
9
12
  _KNOWN_DEVICE_TYPES = {"real_device", "emulator", "simulator"}
10
13
  _KNOWN_CONNECTION_TYPES = {"usb", "network", "virtual"}
@@ -74,3 +77,14 @@ def build_error_session_payload(
74
77
  "error_type": type(exc).__name__,
75
78
  "error_message": str(exc),
76
79
  }
80
+
81
+
82
+ def resolve_device_handle_from_driver(driver: Any, *, client: GridFleetClient) -> dict[str, Any]:
83
+ """Resolve a canonical device handle from a running WebDriver session."""
84
+ caps = getattr(driver, "capabilities", None) or {}
85
+ if not isinstance(caps, dict):
86
+ raise RuntimeError("driver capabilities missing appium:udid; cannot resolve device handle")
87
+ target = caps.get("appium:udid") or caps.get("appium:deviceName")
88
+ if not target:
89
+ raise RuntimeError("driver capabilities missing appium:udid; cannot resolve device handle")
90
+ return client.get_device_by_connection_target(str(target))
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gridfleet-testkit"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  description = "Supported pytest and run-orchestration helpers for GridFleet integrations"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"