gridfleet-testkit 0.1.0__py3-none-any.whl
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/__init__.py +29 -0
- gridfleet_testkit/appium.py +167 -0
- gridfleet_testkit/client.py +242 -0
- gridfleet_testkit/py.typed +1 -0
- gridfleet_testkit/pytest_plugin.py +247 -0
- gridfleet_testkit-0.1.0.dist-info/METADATA +253 -0
- gridfleet_testkit-0.1.0.dist-info/RECORD +9 -0
- gridfleet_testkit-0.1.0.dist-info/WHEEL +4 -0
- gridfleet_testkit-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Supported Python integration helpers for GridFleet."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .appium import (
|
|
6
|
+
build_appium_options,
|
|
7
|
+
create_appium_driver,
|
|
8
|
+
get_connection_target_from_driver,
|
|
9
|
+
get_device_config_for_driver,
|
|
10
|
+
)
|
|
11
|
+
from .client import GRID_URL, GRIDFLEET_API_URL, GridFleetClient, HeartbeatThread, register_run_cleanup
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = version("gridfleet-testkit")
|
|
15
|
+
except PackageNotFoundError:
|
|
16
|
+
__version__ = "0.0.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"GRIDFLEET_API_URL",
|
|
20
|
+
"GRID_URL",
|
|
21
|
+
"GridFleetClient",
|
|
22
|
+
"HeartbeatThread",
|
|
23
|
+
"__version__",
|
|
24
|
+
"build_appium_options",
|
|
25
|
+
"create_appium_driver",
|
|
26
|
+
"get_connection_target_from_driver",
|
|
27
|
+
"get_device_config_for_driver",
|
|
28
|
+
"register_run_cleanup",
|
|
29
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Public Appium driver helpers for GridFleet integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
|
|
11
|
+
from .client import GridFleetClient
|
|
12
|
+
|
|
13
|
+
from .client import GRID_URL
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _catalog_payload(catalog_client: Any | None) -> dict[str, Any]:
|
|
17
|
+
if catalog_client is None:
|
|
18
|
+
from .client import GridFleetClient
|
|
19
|
+
|
|
20
|
+
catalog_client = GridFleetClient()
|
|
21
|
+
if hasattr(catalog_client, "get_driver_pack_catalog"):
|
|
22
|
+
payload = catalog_client.get_driver_pack_catalog()
|
|
23
|
+
elif callable(catalog_client):
|
|
24
|
+
payload = catalog_client()
|
|
25
|
+
else:
|
|
26
|
+
payload = catalog_client
|
|
27
|
+
if isinstance(payload, dict):
|
|
28
|
+
return payload
|
|
29
|
+
if isinstance(payload, list):
|
|
30
|
+
return {"packs": payload}
|
|
31
|
+
raise ValueError("Driver pack catalog client returned an invalid payload")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _enabled_platform_matches(catalog: dict[str, Any], platform_id: str) -> list[tuple[dict[str, Any], dict[str, Any]]]:
|
|
35
|
+
packs = catalog.get("packs")
|
|
36
|
+
if not isinstance(packs, list):
|
|
37
|
+
raise ValueError("Driver pack catalog payload must include a packs list")
|
|
38
|
+
matches: list[tuple[dict[str, Any], dict[str, Any]]] = []
|
|
39
|
+
for pack in packs:
|
|
40
|
+
if not isinstance(pack, dict) or pack.get("state") != "enabled":
|
|
41
|
+
continue
|
|
42
|
+
platforms = pack.get("platforms")
|
|
43
|
+
if not isinstance(platforms, list):
|
|
44
|
+
continue
|
|
45
|
+
for platform in platforms:
|
|
46
|
+
if isinstance(platform, dict) and platform.get("id") == platform_id:
|
|
47
|
+
matches.append((pack, platform))
|
|
48
|
+
return matches
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_pack_platform(
|
|
52
|
+
*,
|
|
53
|
+
pack_id: str | None,
|
|
54
|
+
platform_id: str | None,
|
|
55
|
+
catalog_client: Any | None,
|
|
56
|
+
) -> tuple[str, dict[str, Any]]:
|
|
57
|
+
resolved_pack_id = pack_id or os.getenv("GRIDFLEET_TESTKIT_PACK_ID")
|
|
58
|
+
resolved_platform_id = platform_id or os.getenv("GRIDFLEET_TESTKIT_PLATFORM_ID")
|
|
59
|
+
if not resolved_platform_id:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"Appium options require pack_id + platform_id, platform_id with an unambiguous catalog match, "
|
|
62
|
+
"or an explicit raw platformName capability."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
catalog = _catalog_payload(catalog_client)
|
|
66
|
+
matches = _enabled_platform_matches(catalog, resolved_platform_id)
|
|
67
|
+
if resolved_pack_id:
|
|
68
|
+
for pack, platform in matches:
|
|
69
|
+
if pack.get("id") == resolved_pack_id:
|
|
70
|
+
return resolved_pack_id, platform
|
|
71
|
+
raise ValueError(f"Enabled driver pack platform {resolved_pack_id}:{resolved_platform_id} was not found")
|
|
72
|
+
|
|
73
|
+
if len(matches) == 1:
|
|
74
|
+
pack, platform = matches[0]
|
|
75
|
+
pack_id_value = pack.get("id")
|
|
76
|
+
if not isinstance(pack_id_value, str) or not pack_id_value:
|
|
77
|
+
raise ValueError("Driver pack catalog entry is missing id")
|
|
78
|
+
return pack_id_value, platform
|
|
79
|
+
if len(matches) > 1:
|
|
80
|
+
raise ValueError(f"Multiple enabled driver packs provide platform_id {resolved_platform_id!r}; pass pack_id")
|
|
81
|
+
raise ValueError(f"Enabled driver pack platform for platform_id {resolved_platform_id!r} was not found")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _required_platform_string(platform: dict[str, Any], key: str) -> str:
|
|
85
|
+
value = platform.get(key)
|
|
86
|
+
if not isinstance(value, str) or not value:
|
|
87
|
+
raise ValueError(f"Driver pack platform is missing {key}")
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_appium_options(
|
|
92
|
+
*,
|
|
93
|
+
pack_id: str | None = None,
|
|
94
|
+
platform_id: str | None = None,
|
|
95
|
+
capabilities: Mapping[str, Any] | None = None,
|
|
96
|
+
test_name: str | None = None,
|
|
97
|
+
catalog_client: Any | None = None,
|
|
98
|
+
) -> Any:
|
|
99
|
+
"""Build Appium options from driver-pack catalog platform metadata."""
|
|
100
|
+
from appium.options.common import AppiumOptions
|
|
101
|
+
|
|
102
|
+
params = dict(capabilities or {})
|
|
103
|
+
explicit_platform_name = params.get("platformName")
|
|
104
|
+
if explicit_platform_name is not None and (pack_id is not None or platform_id is not None):
|
|
105
|
+
raise ValueError("Use either pack_id/platform_id or the raw platformName capability, not both.")
|
|
106
|
+
|
|
107
|
+
options = AppiumOptions()
|
|
108
|
+
if explicit_platform_name is None:
|
|
109
|
+
_pack_id, platform_data = _resolve_pack_platform(
|
|
110
|
+
pack_id=pack_id,
|
|
111
|
+
platform_id=platform_id,
|
|
112
|
+
catalog_client=catalog_client,
|
|
113
|
+
)
|
|
114
|
+
options.platform_name = _required_platform_string(platform_data, "appium_platform_name")
|
|
115
|
+
options.set_capability("appium:automationName", _required_platform_string(platform_data, "automation_name"))
|
|
116
|
+
options.set_capability("appium:platform", _required_platform_string(platform_data, "id"))
|
|
117
|
+
|
|
118
|
+
for key, value in params.items():
|
|
119
|
+
options.set_capability(key, value)
|
|
120
|
+
|
|
121
|
+
if test_name is not None:
|
|
122
|
+
options.set_capability("gridfleet:testName", test_name)
|
|
123
|
+
return options
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def create_appium_driver(
|
|
127
|
+
*,
|
|
128
|
+
pack_id: str | None = None,
|
|
129
|
+
platform_id: str | None = None,
|
|
130
|
+
capabilities: Mapping[str, Any] | None = None,
|
|
131
|
+
test_name: str | None = None,
|
|
132
|
+
grid_url: str = GRID_URL,
|
|
133
|
+
catalog_client: Any | None = None,
|
|
134
|
+
) -> Any:
|
|
135
|
+
"""Create an Appium remote driver through Selenium Grid."""
|
|
136
|
+
from appium import webdriver
|
|
137
|
+
|
|
138
|
+
options = build_appium_options(
|
|
139
|
+
pack_id=pack_id,
|
|
140
|
+
platform_id=platform_id,
|
|
141
|
+
capabilities=capabilities,
|
|
142
|
+
test_name=test_name,
|
|
143
|
+
catalog_client=catalog_client,
|
|
144
|
+
)
|
|
145
|
+
return webdriver.Remote(grid_url, options=options)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_connection_target_from_driver(driver: Any) -> str:
|
|
149
|
+
"""Return the runtime connection target from a live Appium driver."""
|
|
150
|
+
capabilities = driver.capabilities
|
|
151
|
+
connection_target = capabilities.get("appium:udid")
|
|
152
|
+
if not isinstance(connection_target, str) or not connection_target:
|
|
153
|
+
raise ValueError("Could not determine device connection target from session capabilities")
|
|
154
|
+
return connection_target
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_device_config_for_driver(
|
|
158
|
+
driver: Any,
|
|
159
|
+
*,
|
|
160
|
+
gridfleet_client: GridFleetClient | None = None,
|
|
161
|
+
reveal: bool = True,
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
"""Fetch device config for a live Appium driver using its runtime connection target."""
|
|
164
|
+
from .client import GridFleetClient
|
|
165
|
+
|
|
166
|
+
client = gridfleet_client or GridFleetClient()
|
|
167
|
+
return client.get_device_config(get_connection_target_from_driver(driver), reveal=reveal)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Public GridFleet client helpers for external test suites."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
GRID_URL = os.getenv("GRID_URL", "http://localhost:4444")
|
|
16
|
+
GRIDFLEET_API_URL = os.getenv("GRIDFLEET_API_URL", "http://localhost:8000/api")
|
|
17
|
+
GRIDFLEET_TESTKIT_USERNAME = os.getenv("GRIDFLEET_TESTKIT_USERNAME")
|
|
18
|
+
GRIDFLEET_TESTKIT_PASSWORD = os.getenv("GRIDFLEET_TESTKIT_PASSWORD")
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("gridfleet_testkit")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _default_auth() -> httpx.BasicAuth | None:
|
|
24
|
+
"""Build httpx Basic auth from env vars, or return None when unset."""
|
|
25
|
+
username = GRIDFLEET_TESTKIT_USERNAME
|
|
26
|
+
password = GRIDFLEET_TESTKIT_PASSWORD
|
|
27
|
+
if not username or not password:
|
|
28
|
+
return None
|
|
29
|
+
return httpx.BasicAuth(username, password)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HeartbeatThread(threading.Thread):
|
|
33
|
+
"""Background thread that sends periodic heartbeat pings for an active test run."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
base_url: str,
|
|
38
|
+
run_id: str,
|
|
39
|
+
interval: int = 30,
|
|
40
|
+
auth: httpx.BasicAuth | None = None,
|
|
41
|
+
):
|
|
42
|
+
super().__init__(daemon=True)
|
|
43
|
+
self.base_url = base_url.rstrip("/")
|
|
44
|
+
self.run_id = run_id
|
|
45
|
+
self.interval = interval
|
|
46
|
+
self._auth = auth
|
|
47
|
+
self._stop_event = threading.Event()
|
|
48
|
+
|
|
49
|
+
def run(self) -> None:
|
|
50
|
+
while not self._stop_event.wait(self.interval):
|
|
51
|
+
try:
|
|
52
|
+
resp = httpx.post(
|
|
53
|
+
f"{self.base_url}/runs/{self.run_id}/heartbeat",
|
|
54
|
+
timeout=10,
|
|
55
|
+
auth=self._auth,
|
|
56
|
+
)
|
|
57
|
+
resp.raise_for_status()
|
|
58
|
+
result = resp.json()
|
|
59
|
+
if result.get("state") in ("expired", "cancelled"):
|
|
60
|
+
logger.warning("Run %s is %s, stopping heartbeat", self.run_id, result["state"])
|
|
61
|
+
break
|
|
62
|
+
except Exception:
|
|
63
|
+
logger.debug("Heartbeat failed for run %s, will retry", self.run_id)
|
|
64
|
+
|
|
65
|
+
def stop(self) -> None:
|
|
66
|
+
self._stop_event.set()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class GridFleetClient:
|
|
70
|
+
"""Client for the GridFleet API, used by test fixtures and CI flows."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
base_url: str = GRIDFLEET_API_URL,
|
|
75
|
+
auth: httpx.BasicAuth | None = None,
|
|
76
|
+
):
|
|
77
|
+
self.base_url = base_url.rstrip("/")
|
|
78
|
+
self._auth = auth if auth is not None else _default_auth()
|
|
79
|
+
|
|
80
|
+
def get_device_config(self, connection_target: str, reveal: bool = True) -> dict[str, Any]:
|
|
81
|
+
"""Fetch device config by looking up the current runtime connection target."""
|
|
82
|
+
resp = httpx.get(
|
|
83
|
+
f"{self.base_url}/devices",
|
|
84
|
+
params={"connection_target": connection_target},
|
|
85
|
+
timeout=10,
|
|
86
|
+
auth=self._auth,
|
|
87
|
+
)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
devices = cast("list[dict[str, Any]]", resp.json())
|
|
90
|
+
if not devices:
|
|
91
|
+
raise ValueError(f"No device found with connection target: {connection_target}")
|
|
92
|
+
device_id = devices[0]["id"]
|
|
93
|
+
config_resp = httpx.get(
|
|
94
|
+
f"{self.base_url}/devices/{device_id}/config",
|
|
95
|
+
params={"reveal": str(reveal).lower()},
|
|
96
|
+
timeout=10,
|
|
97
|
+
auth=self._auth,
|
|
98
|
+
)
|
|
99
|
+
config_resp.raise_for_status()
|
|
100
|
+
return cast("dict[str, Any]", config_resp.json())
|
|
101
|
+
|
|
102
|
+
def get_device_capabilities(self, device_id: str) -> dict[str, Any]:
|
|
103
|
+
"""Fetch the current Appium capabilities for a specific device."""
|
|
104
|
+
resp = httpx.get(
|
|
105
|
+
f"{self.base_url}/devices/{device_id}/capabilities",
|
|
106
|
+
timeout=10,
|
|
107
|
+
auth=self._auth,
|
|
108
|
+
)
|
|
109
|
+
resp.raise_for_status()
|
|
110
|
+
return cast("dict[str, Any]", resp.json())
|
|
111
|
+
|
|
112
|
+
def get_driver_pack_catalog(self) -> dict[str, Any]:
|
|
113
|
+
"""Fetch enabled driver pack catalog data used for Appium platform selection."""
|
|
114
|
+
resp = httpx.get(
|
|
115
|
+
f"{self.base_url}/driver-packs/catalog",
|
|
116
|
+
timeout=10,
|
|
117
|
+
auth=self._auth,
|
|
118
|
+
)
|
|
119
|
+
resp.raise_for_status()
|
|
120
|
+
return cast("dict[str, Any]", resp.json())
|
|
121
|
+
|
|
122
|
+
def reserve_devices(
|
|
123
|
+
self,
|
|
124
|
+
name: str,
|
|
125
|
+
requirements: list[dict[str, Any]],
|
|
126
|
+
ttl_minutes: int = 60,
|
|
127
|
+
heartbeat_timeout_sec: int = 120,
|
|
128
|
+
created_by: str | None = None,
|
|
129
|
+
) -> dict[str, Any]:
|
|
130
|
+
"""Reserve devices for a test run and return the manager response."""
|
|
131
|
+
resp = httpx.post(
|
|
132
|
+
f"{self.base_url}/runs",
|
|
133
|
+
json={
|
|
134
|
+
"name": name,
|
|
135
|
+
"requirements": requirements,
|
|
136
|
+
"ttl_minutes": ttl_minutes,
|
|
137
|
+
"heartbeat_timeout_sec": heartbeat_timeout_sec,
|
|
138
|
+
"created_by": created_by,
|
|
139
|
+
},
|
|
140
|
+
timeout=30,
|
|
141
|
+
auth=self._auth,
|
|
142
|
+
)
|
|
143
|
+
resp.raise_for_status()
|
|
144
|
+
return cast("dict[str, Any]", resp.json())
|
|
145
|
+
|
|
146
|
+
def signal_ready(self, run_id: str) -> None:
|
|
147
|
+
httpx.post(
|
|
148
|
+
f"{self.base_url}/runs/{run_id}/ready",
|
|
149
|
+
timeout=10,
|
|
150
|
+
auth=self._auth,
|
|
151
|
+
).raise_for_status()
|
|
152
|
+
|
|
153
|
+
def signal_active(self, run_id: str) -> None:
|
|
154
|
+
httpx.post(
|
|
155
|
+
f"{self.base_url}/runs/{run_id}/active",
|
|
156
|
+
timeout=10,
|
|
157
|
+
auth=self._auth,
|
|
158
|
+
).raise_for_status()
|
|
159
|
+
|
|
160
|
+
def heartbeat(self, run_id: str) -> dict[str, Any]:
|
|
161
|
+
resp = httpx.post(
|
|
162
|
+
f"{self.base_url}/runs/{run_id}/heartbeat",
|
|
163
|
+
timeout=10,
|
|
164
|
+
auth=self._auth,
|
|
165
|
+
)
|
|
166
|
+
resp.raise_for_status()
|
|
167
|
+
return cast("dict[str, Any]", resp.json())
|
|
168
|
+
|
|
169
|
+
def claim_device(self, run_id: str, *, worker_id: str) -> dict[str, Any]:
|
|
170
|
+
resp = httpx.post(
|
|
171
|
+
f"{self.base_url}/runs/{run_id}/claim",
|
|
172
|
+
json={"worker_id": worker_id},
|
|
173
|
+
timeout=10,
|
|
174
|
+
auth=self._auth,
|
|
175
|
+
)
|
|
176
|
+
resp.raise_for_status()
|
|
177
|
+
return cast("dict[str, Any]", resp.json())
|
|
178
|
+
|
|
179
|
+
def release_device(self, run_id: str, *, device_id: str, worker_id: str) -> None:
|
|
180
|
+
resp = httpx.post(
|
|
181
|
+
f"{self.base_url}/runs/{run_id}/release",
|
|
182
|
+
json={"device_id": device_id, "worker_id": worker_id},
|
|
183
|
+
timeout=10,
|
|
184
|
+
auth=self._auth,
|
|
185
|
+
)
|
|
186
|
+
resp.raise_for_status()
|
|
187
|
+
|
|
188
|
+
def report_preparation_failure(
|
|
189
|
+
self,
|
|
190
|
+
run_id: str,
|
|
191
|
+
device_id: str,
|
|
192
|
+
message: str,
|
|
193
|
+
source: str = "ci_preparation",
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
resp = httpx.post(
|
|
196
|
+
f"{self.base_url}/runs/{run_id}/devices/{device_id}/preparation-failed",
|
|
197
|
+
json={"message": message, "source": source},
|
|
198
|
+
timeout=10,
|
|
199
|
+
auth=self._auth,
|
|
200
|
+
)
|
|
201
|
+
resp.raise_for_status()
|
|
202
|
+
return cast("dict[str, Any]", resp.json())
|
|
203
|
+
|
|
204
|
+
def complete_run(self, run_id: str) -> None:
|
|
205
|
+
httpx.post(
|
|
206
|
+
f"{self.base_url}/runs/{run_id}/complete",
|
|
207
|
+
timeout=10,
|
|
208
|
+
auth=self._auth,
|
|
209
|
+
).raise_for_status()
|
|
210
|
+
|
|
211
|
+
def cancel_run(self, run_id: str) -> None:
|
|
212
|
+
httpx.post(
|
|
213
|
+
f"{self.base_url}/runs/{run_id}/cancel",
|
|
214
|
+
timeout=10,
|
|
215
|
+
auth=self._auth,
|
|
216
|
+
).raise_for_status()
|
|
217
|
+
|
|
218
|
+
def start_heartbeat(self, run_id: str, interval: int = 30) -> HeartbeatThread:
|
|
219
|
+
thread = HeartbeatThread(self.base_url, run_id, interval, auth=self._auth)
|
|
220
|
+
thread.start()
|
|
221
|
+
return thread
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def register_run_cleanup(
|
|
225
|
+
client: GridFleetClient,
|
|
226
|
+
run_id: str,
|
|
227
|
+
heartbeat_thread: HeartbeatThread | None = None,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Register exit and signal handlers that release reserved devices."""
|
|
230
|
+
|
|
231
|
+
def cleanup(*_args: object) -> None:
|
|
232
|
+
if heartbeat_thread:
|
|
233
|
+
heartbeat_thread.stop()
|
|
234
|
+
try:
|
|
235
|
+
client.complete_run(run_id)
|
|
236
|
+
except Exception:
|
|
237
|
+
with contextlib.suppress(Exception):
|
|
238
|
+
client.cancel_run(run_id)
|
|
239
|
+
|
|
240
|
+
atexit.register(cleanup)
|
|
241
|
+
signal.signal(signal.SIGTERM, cleanup)
|
|
242
|
+
signal.signal(signal.SIGINT, cleanup)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Supported pytest plugin surface for GridFleet Appium tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from .appium import (
|
|
13
|
+
build_appium_options,
|
|
14
|
+
get_device_config_for_driver,
|
|
15
|
+
)
|
|
16
|
+
from .client import GRID_URL, GRIDFLEET_API_URL, GridFleetClient, _default_auth
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Generator
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("gridfleet_testkit")
|
|
22
|
+
KNOWN_DEVICE_TYPES = {"real_device", "emulator", "simulator"}
|
|
23
|
+
KNOWN_CONNECTION_TYPES = {"usb", "network"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_usage_error_message(message: str) -> str:
|
|
27
|
+
if message.startswith("Appium options require"):
|
|
28
|
+
return (
|
|
29
|
+
"appium_driver requires pack_id + platform_id, platform_id with an unambiguous catalog match, "
|
|
30
|
+
"or an explicit 'platformName' capability."
|
|
31
|
+
)
|
|
32
|
+
return message
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _report_session_status(session_id: str, status: str) -> None:
|
|
36
|
+
"""Report final session status to the GridFleet API."""
|
|
37
|
+
try:
|
|
38
|
+
resp = httpx.patch(
|
|
39
|
+
f"{GRIDFLEET_API_URL}/sessions/{session_id}/status",
|
|
40
|
+
json={"status": status},
|
|
41
|
+
timeout=5,
|
|
42
|
+
auth=_default_auth(),
|
|
43
|
+
)
|
|
44
|
+
resp.raise_for_status()
|
|
45
|
+
except httpx.HTTPError as exc:
|
|
46
|
+
logger.warning("Failed to report session status to GridFleet: %s", exc)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _register_session(driver: Any, test_name: str) -> None:
|
|
50
|
+
"""Register a newly-created Grid session with the GridFleet API."""
|
|
51
|
+
capabilities = getattr(driver, "capabilities", {})
|
|
52
|
+
if not isinstance(capabilities, dict):
|
|
53
|
+
capabilities = {}
|
|
54
|
+
|
|
55
|
+
payload: dict[str, Any] = {
|
|
56
|
+
"session_id": driver.session_id,
|
|
57
|
+
"test_name": test_name,
|
|
58
|
+
}
|
|
59
|
+
device_id = capabilities.get("appium:gridfleet:deviceId") or capabilities.get("gridfleet:deviceId")
|
|
60
|
+
if isinstance(device_id, str) and device_id:
|
|
61
|
+
payload["device_id"] = device_id
|
|
62
|
+
|
|
63
|
+
connection_target = capabilities.get("appium:udid") or capabilities.get("appium:deviceName")
|
|
64
|
+
if isinstance(connection_target, str) and connection_target:
|
|
65
|
+
payload["connection_target"] = connection_target
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
resp = httpx.post(
|
|
69
|
+
f"{GRIDFLEET_API_URL}/sessions",
|
|
70
|
+
json=payload,
|
|
71
|
+
timeout=5,
|
|
72
|
+
auth=_default_auth(),
|
|
73
|
+
)
|
|
74
|
+
resp.raise_for_status()
|
|
75
|
+
except httpx.HTTPError as exc:
|
|
76
|
+
logger.warning("Failed to register session with GridFleet: %s", exc)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _register_error_session(payload: dict[str, Any]) -> None:
|
|
80
|
+
"""Register a device-less error session with the GridFleet API.
|
|
81
|
+
|
|
82
|
+
Used when driver creation fails before a Grid session is established so
|
|
83
|
+
the failure is still visible in the Dashboard Sessions view.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
resp = httpx.post(
|
|
87
|
+
f"{GRIDFLEET_API_URL}/sessions",
|
|
88
|
+
json=payload,
|
|
89
|
+
timeout=5,
|
|
90
|
+
auth=_default_auth(),
|
|
91
|
+
)
|
|
92
|
+
resp.raise_for_status()
|
|
93
|
+
except httpx.HTTPError as exc:
|
|
94
|
+
logger.warning("Failed to register error session with GridFleet: %s", exc)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_driver_options(request: pytest.FixtureRequest) -> Any:
|
|
98
|
+
params = getattr(request, "param", {})
|
|
99
|
+
capabilities = {key: value for key, value in params.items() if key not in {"pack_id", "platform_id"}}
|
|
100
|
+
try:
|
|
101
|
+
return build_appium_options(
|
|
102
|
+
pack_id=params.get("pack_id"),
|
|
103
|
+
platform_id=params.get("platform_id"),
|
|
104
|
+
capabilities=capabilities,
|
|
105
|
+
test_name=request.node.name,
|
|
106
|
+
catalog_client=GridFleetClient(),
|
|
107
|
+
)
|
|
108
|
+
except ValueError as exc:
|
|
109
|
+
raise pytest.UsageError(_normalize_usage_error_message(str(exc))) from exc
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _raw_attempted_capabilities(options: Any) -> dict[str, Any]:
|
|
113
|
+
capabilities = getattr(options, "capabilities", {})
|
|
114
|
+
raw_capabilities = dict(capabilities) if isinstance(capabilities, dict) else {}
|
|
115
|
+
platform_name = getattr(options, "platform_name", None)
|
|
116
|
+
if isinstance(platform_name, str) and platform_name:
|
|
117
|
+
raw_capabilities.setdefault("platformName", platform_name)
|
|
118
|
+
return raw_capabilities
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _infer_requested_platform_id(params: dict[str, Any], raw_capabilities: dict[str, Any]) -> str | None:
|
|
122
|
+
platform_id = params.get("platform_id")
|
|
123
|
+
if isinstance(platform_id, str) and platform_id:
|
|
124
|
+
return platform_id
|
|
125
|
+
platform_hint = raw_capabilities.get("appium:platform")
|
|
126
|
+
return platform_hint if isinstance(platform_hint, str) and platform_hint else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _read_enum_capability(raw_capabilities: dict[str, Any], *keys: str, allowed: set[str]) -> str | None:
|
|
130
|
+
for key in keys:
|
|
131
|
+
value = raw_capabilities.get(key)
|
|
132
|
+
if isinstance(value, str) and value in allowed:
|
|
133
|
+
return value
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _build_error_session_payload(
|
|
138
|
+
*,
|
|
139
|
+
request: pytest.FixtureRequest,
|
|
140
|
+
options: Any,
|
|
141
|
+
exc: Exception,
|
|
142
|
+
session_id: str,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
params = getattr(request, "param", {})
|
|
145
|
+
raw_capabilities = _raw_attempted_capabilities(options)
|
|
146
|
+
payload: dict[str, Any] = {
|
|
147
|
+
"session_id": session_id,
|
|
148
|
+
"test_name": request.node.name,
|
|
149
|
+
"status": "error",
|
|
150
|
+
"requested_platform_id": _infer_requested_platform_id(params, raw_capabilities),
|
|
151
|
+
"requested_device_type": _read_enum_capability(
|
|
152
|
+
raw_capabilities,
|
|
153
|
+
"appium:device_type",
|
|
154
|
+
"device_type",
|
|
155
|
+
allowed=KNOWN_DEVICE_TYPES,
|
|
156
|
+
),
|
|
157
|
+
"requested_connection_type": _read_enum_capability(
|
|
158
|
+
raw_capabilities,
|
|
159
|
+
"appium:connection_type",
|
|
160
|
+
"connection_type",
|
|
161
|
+
allowed=KNOWN_CONNECTION_TYPES,
|
|
162
|
+
),
|
|
163
|
+
"requested_capabilities": raw_capabilities,
|
|
164
|
+
"error_type": type(exc).__name__,
|
|
165
|
+
"error_message": str(exc),
|
|
166
|
+
}
|
|
167
|
+
return payload
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.fixture
|
|
171
|
+
def appium_driver(request: pytest.FixtureRequest) -> Generator[Any, None, None]:
|
|
172
|
+
"""
|
|
173
|
+
Create an Appium Remote driver through the Selenium Grid.
|
|
174
|
+
|
|
175
|
+
Parametrize with a dict of pack/catalog selection plus capabilities:
|
|
176
|
+
@pytest.mark.parametrize(
|
|
177
|
+
"appium_driver",
|
|
178
|
+
[{"pack_id": "appium-uiautomator2", "platform_id": "android_mobile"}],
|
|
179
|
+
indirect=True,
|
|
180
|
+
)
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
options = _build_driver_options(request)
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
raise pytest.UsageError(_normalize_usage_error_message(str(exc))) from exc
|
|
186
|
+
from appium import webdriver
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
driver = webdriver.Remote(GRID_URL, options=options)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
# Driver creation failed before a Grid session was established (e.g.
|
|
192
|
+
# SessionNotCreatedException). Register a device-less error session so the
|
|
193
|
+
# failure is visible in the Dashboard Sessions view.
|
|
194
|
+
synthetic_id = f"error-{uuid.uuid4()}"
|
|
195
|
+
_register_error_session(
|
|
196
|
+
_build_error_session_payload(request=request, options=options, exc=exc, session_id=synthetic_id)
|
|
197
|
+
)
|
|
198
|
+
raise
|
|
199
|
+
session_id = driver.session_id
|
|
200
|
+
if not isinstance(session_id, str) or not session_id:
|
|
201
|
+
raise RuntimeError("Created Appium driver did not expose a session ID")
|
|
202
|
+
_register_session(driver, request.node.name)
|
|
203
|
+
|
|
204
|
+
yield driver
|
|
205
|
+
|
|
206
|
+
status: str | None = None
|
|
207
|
+
if hasattr(request.node, "rep_call"):
|
|
208
|
+
if request.node.rep_call.passed:
|
|
209
|
+
status = "passed"
|
|
210
|
+
elif request.node.rep_call.failed:
|
|
211
|
+
status = "failed"
|
|
212
|
+
else:
|
|
213
|
+
status = "error"
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
driver.quit()
|
|
217
|
+
finally:
|
|
218
|
+
if status is not None:
|
|
219
|
+
_report_session_status(session_id, status)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
|
223
|
+
def pytest_runtest_makereport(item: pytest.Item) -> Generator[Any, None, None]:
|
|
224
|
+
"""Store the test outcome on the item for fixture teardown reporting."""
|
|
225
|
+
outcome: Any = yield
|
|
226
|
+
rep = outcome.get_result()
|
|
227
|
+
setattr(item, f"rep_{rep.when}", rep)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@pytest.fixture(scope="session")
|
|
231
|
+
def gridfleet_client() -> GridFleetClient:
|
|
232
|
+
return GridFleetClient()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@pytest.fixture
|
|
236
|
+
def device_config(appium_driver: Any, gridfleet_client: GridFleetClient) -> dict[str, Any]:
|
|
237
|
+
"""
|
|
238
|
+
Fetch device config after the Grid assigns a runtime connection target.
|
|
239
|
+
|
|
240
|
+
Usage:
|
|
241
|
+
def test_login(appium_driver, device_config):
|
|
242
|
+
username = device_config["app_username"]
|
|
243
|
+
"""
|
|
244
|
+
try:
|
|
245
|
+
return get_device_config_for_driver(appium_driver, gridfleet_client=gridfleet_client)
|
|
246
|
+
except ValueError:
|
|
247
|
+
pytest.skip("Could not determine device connection target from session capabilities")
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gridfleet-testkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Supported pytest and run-orchestration helpers for GridFleet integrations
|
|
5
|
+
Project-URL: Homepage, https://github.com/quidow/gridfleet
|
|
6
|
+
Project-URL: Repository, https://github.com/quidow/gridfleet
|
|
7
|
+
Project-URL: Documentation, https://github.com/quidow/gridfleet/tree/main/docs/reference/testkit.md
|
|
8
|
+
Project-URL: Issues, https://github.com/quidow/gridfleet/issues
|
|
9
|
+
Project-URL: Security, https://github.com/quidow/gridfleet/security/advisories/new
|
|
10
|
+
Author: GridFleet contributors
|
|
11
|
+
License-Expression: Apache-2.0
|
|
12
|
+
Keywords: appium,gridfleet,pytest,selenium,testing
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: Pytest
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: httpx<1,>=0.27
|
|
26
|
+
Requires-Dist: pytest>=9.0.3
|
|
27
|
+
Provides-Extra: appium
|
|
28
|
+
Requires-Dist: appium-python-client>=4.5; extra == 'appium'
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: mypy>=1.20.2; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=9.0.3; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.15.12; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# GridFleet Testkit
|
|
36
|
+
|
|
37
|
+
`testkit/` is the supported Python integration surface for external pytest/Appium suites that run through GridFleet.
|
|
38
|
+
|
|
39
|
+
## What This Package Owns
|
|
40
|
+
|
|
41
|
+
- Stable import root: `gridfleet_testkit`
|
|
42
|
+
- Supported pytest plugin: `gridfleet_testkit.pytest_plugin`
|
|
43
|
+
- Supported public helpers:
|
|
44
|
+
- `build_appium_options`
|
|
45
|
+
- `create_appium_driver`
|
|
46
|
+
- `get_connection_target_from_driver`
|
|
47
|
+
- `get_device_config_for_driver`
|
|
48
|
+
- `GridFleetClient`
|
|
49
|
+
- `HeartbeatThread`
|
|
50
|
+
- `register_run_cleanup`
|
|
51
|
+
- Manual hardware examples under `testkit/examples/`
|
|
52
|
+
|
|
53
|
+
## What It Does Not Own
|
|
54
|
+
|
|
55
|
+
- Appium server installation or host-level driver setup
|
|
56
|
+
- Selenium Grid lifecycle
|
|
57
|
+
- Device registration, verification, or readiness setup
|
|
58
|
+
- CI orchestration beyond the documented client helpers
|
|
59
|
+
|
|
60
|
+
The supported contract is the installable package and documented import pattern. The example scripts are onboarding aids, not CI-backed conformance tests.
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
From PyPI:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install "gridfleet-testkit[appium]"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
From a local checkout:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
uv pip install -e ./testkit[appium]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
From a copied `testkit/` directory inside another repository:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
uv pip install -e ./testkit[appium]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
From a Git checkout or VCS URL that contains this package:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv pip install "git+https://github.com/<org>/<repo>.git#subdirectory=testkit"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The package supports Python 3.10 and newer.
|
|
89
|
+
|
|
90
|
+
## Environment
|
|
91
|
+
|
|
92
|
+
| Variable | Default | Meaning |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `GRID_URL` | `http://localhost:4444` | Selenium Grid hub URL used by the pytest Appium fixture |
|
|
95
|
+
| `GRIDFLEET_API_URL` | `http://localhost:8000/api` | GridFleet API base used for session reporting, config lookup, run helpers, and driver-pack catalog lookup |
|
|
96
|
+
| `GRIDFLEET_TESTKIT_USERNAME` | unset | Machine-auth username sent as HTTP Basic auth on every API call. Required when the manager runs with `GRIDFLEET_AUTH_ENABLED=true`. Use the same value as the manager's `GRIDFLEET_MACHINE_AUTH_USERNAME`. |
|
|
97
|
+
| `GRIDFLEET_TESTKIT_PASSWORD` | unset | Machine-auth password sent as HTTP Basic auth on every API call. Required when the manager runs with `GRIDFLEET_AUTH_ENABLED=true`. Use the same value as the manager's `GRIDFLEET_MACHINE_AUTH_PASSWORD`. |
|
|
98
|
+
| `GRIDFLEET_TESTKIT_PACK_ID` | unset | Optional default driver pack id for Appium option building |
|
|
99
|
+
| `GRIDFLEET_TESTKIT_PLATFORM_ID` | unset | Optional default platform id for Appium option building |
|
|
100
|
+
|
|
101
|
+
The package assumes a running GridFleet API, a reachable Selenium Grid hub, and platform-specific Appium driver setup on the registered hosts. When auth is disabled on the manager, leave `GRIDFLEET_TESTKIT_USERNAME` / `GRIDFLEET_TESTKIT_PASSWORD` unset and the testkit will send no `Authorization` header.
|
|
102
|
+
|
|
103
|
+
## Pytest Plugin
|
|
104
|
+
|
|
105
|
+
Load the supported plugin from your test project:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
pytest_plugins = ["gridfleet_testkit.pytest_plugin"]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Minimal usage:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
import pytest
|
|
115
|
+
|
|
116
|
+
@pytest.mark.parametrize(
|
|
117
|
+
"appium_driver",
|
|
118
|
+
[{"pack_id": "appium-uiautomator2", "platform_id": "android_mobile"}],
|
|
119
|
+
indirect=True,
|
|
120
|
+
)
|
|
121
|
+
def test_session_starts(appium_driver):
|
|
122
|
+
assert appium_driver.session_id is not None
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The plugin resolves `pack_id` and `platform_id` against the enabled driver-pack catalog, then injects Appium `platformName`, `appium:automationName`, `appium:platform`, and `gridfleet:testName`.
|
|
126
|
+
|
|
127
|
+
When exactly one enabled pack provides a platform id, `platform_id` alone is accepted. For environment-portable tests, set `GRIDFLEET_TESTKIT_PACK_ID` and `GRIDFLEET_TESTKIT_PLATFORM_ID`, then parametrize with `{}`.
|
|
128
|
+
|
|
129
|
+
If you need raw Appium control instead, omit `pack_id` and `platform_id`, then pass `platformName` as a normal capability key.
|
|
130
|
+
|
|
131
|
+
### Plugin Lifecycle
|
|
132
|
+
|
|
133
|
+
- Creates an Appium session through `GRID_URL`
|
|
134
|
+
- Injects `gridfleet:testName` with the pytest test name
|
|
135
|
+
- Reports final session status back to `GRIDFLEET_API_URL`
|
|
136
|
+
- Exposes `device_config` for post-session config lookup using the runtime connection target
|
|
137
|
+
- Relies on manager-owned runtime isolation for Appium driver sub-ports and XCUITest build paths
|
|
138
|
+
|
|
139
|
+
## Direct Appium Usage
|
|
140
|
+
|
|
141
|
+
If you need to create a driver outside pytest, use the public Appium helpers:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from gridfleet_testkit import create_appium_driver, get_device_config_for_driver
|
|
145
|
+
|
|
146
|
+
driver = create_appium_driver(
|
|
147
|
+
pack_id="appium-uiautomator2",
|
|
148
|
+
platform_id="firetv_real",
|
|
149
|
+
test_name="manual-smoke",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
assert driver.session_id is not None
|
|
154
|
+
device_config = get_device_config_for_driver(driver)
|
|
155
|
+
finally:
|
|
156
|
+
driver.quit()
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`create_appium_driver(...)` reuses the same driver-pack catalog resolver as the pytest fixture. Managed nodes still get their host-scoped runtime allocations from the manager, so callers should not hard-code `systemPort`, `chromedriverPort`, `mjpegServerPort`, `wdaLocalPort`, or `derivedDataPath`. `get_device_config_for_driver(...)` is the non-pytest equivalent of the `device_config` fixture. If you only need the options object, use `build_appium_options(...)`.
|
|
160
|
+
|
|
161
|
+
## Client Helpers
|
|
162
|
+
|
|
163
|
+
| Helper | Purpose |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `GridFleetClient.get_device_config(connection_target, reveal=True)` | Look up a device by runtime connection target and fetch its config |
|
|
166
|
+
| `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
|
|
167
|
+
| `GridFleetClient.reserve_devices(...)` | Create a run/reservation and return the manager response |
|
|
168
|
+
| `GridFleetClient.signal_ready(run_id)` | Move a run to `ready` |
|
|
169
|
+
| `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
|
|
170
|
+
| `GridFleetClient.heartbeat(run_id)` | Send a run heartbeat and read current state |
|
|
171
|
+
| `GridFleetClient.report_preparation_failure(run_id, device_id, message, source="ci_preparation")` | Exclude one reserved device after setup fails |
|
|
172
|
+
| `GridFleetClient.complete_run(run_id)` | Complete a run |
|
|
173
|
+
| `GridFleetClient.cancel_run(run_id)` | Cancel a run |
|
|
174
|
+
| `GridFleetClient.start_heartbeat(run_id, interval=30)` | Start a background heartbeat thread |
|
|
175
|
+
| `register_run_cleanup(client, run_id, heartbeat_thread=None)` | Register `atexit` and signal cleanup that completes or cancels a run |
|
|
176
|
+
|
|
177
|
+
### Reservation Flow
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from gridfleet_testkit import GridFleetClient, register_run_cleanup
|
|
181
|
+
|
|
182
|
+
client = GridFleetClient("http://manager-ip:8000/api")
|
|
183
|
+
|
|
184
|
+
run = client.reserve_devices(
|
|
185
|
+
name="my-test-run",
|
|
186
|
+
requirements=[
|
|
187
|
+
{
|
|
188
|
+
"pack_id": "appium-uiautomator2",
|
|
189
|
+
"platform_id": "firetv_real",
|
|
190
|
+
"os_version": "8",
|
|
191
|
+
"allocation": "all_available",
|
|
192
|
+
"min_count": 1,
|
|
193
|
+
}
|
|
194
|
+
],
|
|
195
|
+
ttl_minutes=45,
|
|
196
|
+
created_by="local-dev",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
run_id = run["id"]
|
|
200
|
+
worker_count = len(run["devices"])
|
|
201
|
+
heartbeat_thread = client.start_heartbeat(run_id, interval=30)
|
|
202
|
+
register_run_cleanup(client, run_id, heartbeat_thread)
|
|
203
|
+
|
|
204
|
+
# If one reserved device fails setup:
|
|
205
|
+
client.report_preparation_failure(
|
|
206
|
+
run_id,
|
|
207
|
+
device_id="device-123",
|
|
208
|
+
message="Driver bootstrap timed out during CI setup",
|
|
209
|
+
source="local-dev",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
client.signal_ready(run_id)
|
|
213
|
+
client.signal_active(run_id)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Use `count` for exact reservations. Use `allocation: "all_available"` when CI should reserve every currently eligible matching device and size its worker pool from `len(run["devices"])`.
|
|
217
|
+
|
|
218
|
+
## Examples
|
|
219
|
+
|
|
220
|
+
Baseline screenshot examples:
|
|
221
|
+
|
|
222
|
+
- `examples/test_android_mobile_screenshot.py`
|
|
223
|
+
- `examples/test_android_tv_screenshot.py`
|
|
224
|
+
- `examples/test_firetv_screenshot.py`
|
|
225
|
+
- `examples/test_ios_simulator_screenshot.py`
|
|
226
|
+
- `examples/test_tvos_screenshot.py`
|
|
227
|
+
- `examples/test_roku_screenshot.py`
|
|
228
|
+
|
|
229
|
+
Advanced example:
|
|
230
|
+
|
|
231
|
+
- `examples/test_roku_sideload_screenshot.py`
|
|
232
|
+
|
|
233
|
+
The baseline examples share the same flow:
|
|
234
|
+
|
|
235
|
+
1. Create a session through Selenium Grid
|
|
236
|
+
2. Print the resolved connection context
|
|
237
|
+
3. Save a screenshot
|
|
238
|
+
4. Assert that the screenshot file exists and is non-empty
|
|
239
|
+
|
|
240
|
+
## Platform Notes
|
|
241
|
+
|
|
242
|
+
- Android Mobile / Android TV / Fire TV:
|
|
243
|
+
- require the UiAutomator2 driver
|
|
244
|
+
- rely on Grid routing hints generated from GridFleet metadata
|
|
245
|
+
- Fire TV:
|
|
246
|
+
- baseline example supports optional `appium:os_version` filtering when you need a specific Fire OS release
|
|
247
|
+
- iOS simulator:
|
|
248
|
+
- baseline example intentionally targets the simulator lane with `appium:device_type=simulator`
|
|
249
|
+
- tvOS:
|
|
250
|
+
- baseline example intentionally targets a real device and assumes the host already satisfies XCUITest and WebDriverAgent prerequisites
|
|
251
|
+
- Roku:
|
|
252
|
+
- screenshot examples install and activate the bundled sample dev app before capture
|
|
253
|
+
- both Roku examples depend on Roku dev credentials
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
gridfleet_testkit/__init__.py,sha256=aIHLcVP6WZeL5C3b1y4wyNX9edR9SVBwyGfn-qHjtNM,764
|
|
2
|
+
gridfleet_testkit/appium.py,sha256=jJgWKpr8MEhBlVdT9S7qlaNBE3voZ75nB-fUMzXXmI4,6269
|
|
3
|
+
gridfleet_testkit/client.py,sha256=-beG-7CIbfIpRXr_75SwcfP_hJ6S3HnLJTgqnNaHgkM,8022
|
|
4
|
+
gridfleet_testkit/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
5
|
+
gridfleet_testkit/pytest_plugin.py,sha256=ldMxAqgCHuG5SkPyEY7S4U6Oxz2Sux5ur-rAryEQdGw,8569
|
|
6
|
+
gridfleet_testkit-0.1.0.dist-info/METADATA,sha256=EBhfyqFSfkwc_jgw0NK0aXEAiZxPyAa6TAziQnYrArA,9993
|
|
7
|
+
gridfleet_testkit-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
gridfleet_testkit-0.1.0.dist-info/entry_points.txt,sha256=L54RzqsaWcj2VehfndWEwgu5FX5J_rCq7dww-e7DoU0,55
|
|
9
|
+
gridfleet_testkit-0.1.0.dist-info/RECORD,,
|