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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ gridfleet = gridfleet_testkit.pytest_plugin