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.
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/CHANGELOG.md +40 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/PKG-INFO +1 -1
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/__init__.py +4 -8
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/allocation.py +4 -15
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/appium.py +1 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/client.py +67 -184
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/pytest_plugin.py +11 -1
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/sessions.py +16 -2
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/pyproject.toml +1 -1
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_allocation.py +20 -38
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_appium.py +1 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_client.py +123 -515
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_package_metadata.py +0 -2
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_pytest_plugin.py +1 -0
- gridfleet_testkit-0.7.0/tests/test_pytest_plugin_grid_run_id_injection.py +56 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_sessions.py +1 -1
- gridfleet_testkit-0.7.0/tests/test_sessions_resolve_device_handle.py +25 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/uv.lock +4 -4
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/.gitignore +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/README.md +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/__init__.py +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/_example_helpers.py +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/assets/hello-world.zip +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_android_mobile_screenshot.py +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_android_tv_screenshot.py +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_firetv_screenshot.py +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_ios_simulator_screenshot.py +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_roku_screenshot.py +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_roku_sideload_screenshot.py +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/examples/test_tvos_screenshot.py +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/py.typed +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_client_test_data.py +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_docs_contract.py +0 -0
- {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.7.0}/tests/test_driver_agnostic_guard.py +0 -0
- {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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
13
|
-
from
|
|
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
|
|
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
|
-
|
|
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
|
|
317
|
-
"""
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|