gridfleet-testkit 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/CHANGELOG.md +11 -0
  2. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/PKG-INFO +1 -1
  3. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/gridfleet_testkit/__init__.py +1 -1
  4. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/gridfleet_testkit/client.py +53 -2
  5. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/pyproject.toml +1 -1
  6. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/tests/test_client.py +120 -14
  7. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/uv.lock +1 -1
  8. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/.gitignore +0 -0
  9. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/README.md +0 -0
  10. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/examples/__init__.py +0 -0
  11. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/examples/_example_helpers.py +0 -0
  12. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/examples/assets/hello-world.zip +0 -0
  13. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/examples/test_android_mobile_screenshot.py +0 -0
  14. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/examples/test_android_tv_screenshot.py +0 -0
  15. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/examples/test_firetv_screenshot.py +0 -0
  16. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/examples/test_ios_simulator_screenshot.py +0 -0
  17. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/examples/test_roku_screenshot.py +0 -0
  18. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/examples/test_roku_sideload_screenshot.py +0 -0
  19. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/examples/test_tvos_screenshot.py +0 -0
  20. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/gridfleet_testkit/allocation.py +0 -0
  21. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/gridfleet_testkit/appium.py +0 -0
  22. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/gridfleet_testkit/py.typed +0 -0
  23. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/gridfleet_testkit/pytest_plugin.py +0 -0
  24. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/gridfleet_testkit/sessions.py +0 -0
  25. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/tests/test_allocation.py +0 -0
  26. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/tests/test_appium.py +0 -0
  27. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/tests/test_client_test_data.py +0 -0
  28. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/tests/test_docs_contract.py +0 -0
  29. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/tests/test_driver_agnostic_guard.py +0 -0
  30. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/tests/test_package_metadata.py +0 -0
  31. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/tests/test_pytest_plugin.py +0 -0
  32. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/tests/test_pytest_plugin_test_data.py +0 -0
  33. {gridfleet_testkit-0.5.0 → gridfleet_testkit-0.6.0}/tests/test_sessions.py +0 -0
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
4
4
 
5
+ ## [0.6.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.5.0...gridfleet-testkit-v0.6.0) (2026-05-10)
6
+
7
+
8
+ ### ⚠ BREAKING CHANGES
9
+
10
+ * **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).
11
+
12
+ ### Features
13
+
14
+ * **backend:** device state model drift fixes (D1-D6) ([#144](https://github.com/quidow/gridfleet/issues/144)) ([09556fd](https://github.com/quidow/gridfleet/commit/09556fdac8ddb458f1655f9001f25240443062fb))
15
+
5
16
  ## [0.5.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.4.0...gridfleet-testkit-v0.5.0) (2026-05-08)
6
17
 
7
18
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridfleet-testkit
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -47,7 +47,7 @@ from .sessions import build_error_session_payload
47
47
  try:
48
48
  __version__ = version("gridfleet-testkit")
49
49
  except PackageNotFoundError:
50
- __version__ = "0.5.0"
50
+ __version__ = "0.6.0"
51
51
 
52
52
  __all__ = [
53
53
  "GRIDFLEET_API_URL",
@@ -600,6 +600,27 @@ class GridFleetClient:
600
600
  return None
601
601
  return cast("dict[str, Any]", resp.json())
602
602
 
603
+ def notify_session_finished(
604
+ self,
605
+ session_id: str,
606
+ *,
607
+ suppress_errors: bool = True,
608
+ ) -> None:
609
+ """Tell the manager the WebDriver session has ended.
610
+
611
+ Idempotent on the backend — repeated calls are a no-op once the
612
+ Session row is marked ended.
613
+ """
614
+ try:
615
+ resp = httpx.post(
616
+ f"{self.base_url}/sessions/{session_id}/finished",
617
+ timeout=5,
618
+ auth=self._auth,
619
+ )
620
+ resp.raise_for_status()
621
+ except (httpx.HTTPError, TypeError, ValueError) as exc:
622
+ _raise_or_warn("notify session finished", suppress_errors, exc)
623
+
603
624
  def update_session_status(
604
625
  self,
605
626
  session_id: str,
@@ -629,7 +650,12 @@ class GridFleetClient:
629
650
  run_id: str | None = None,
630
651
  suppress_errors: bool = True,
631
652
  ) -> dict[str, Any] | None:
632
- """Extract session metadata from an Appium driver and register it."""
653
+ """Extract session metadata from an Appium driver and register it.
654
+
655
+ Also wraps ``driver.quit`` so that the first call after a successful
656
+ registration fires :meth:`notify_session_finished` automatically.
657
+ Errors from notify are suppressed — they must never break the caller.
658
+ """
633
659
  capabilities = getattr(driver, "capabilities", {})
634
660
  if not isinstance(capabilities, dict):
635
661
  capabilities = {}
@@ -638,7 +664,7 @@ class GridFleetClient:
638
664
  raise RuntimeError("Created Appium driver did not expose a session ID")
639
665
  device_id = capabilities.get("appium:gridfleet:deviceId") or capabilities.get("gridfleet:deviceId")
640
666
  connection_target = capabilities.get("appium:udid") or capabilities.get("appium:deviceName")
641
- return self.register_session(
667
+ result = self.register_session(
642
668
  session_id=session_id,
643
669
  test_name=test_name,
644
670
  device_id=device_id if isinstance(device_id, str) and device_id else None,
@@ -647,6 +673,31 @@ class GridFleetClient:
647
673
  run_id=run_id,
648
674
  suppress_errors=suppress_errors,
649
675
  )
676
+ self._wrap_quit_for_notify(driver, session_id)
677
+ return result
678
+
679
+ def _wrap_quit_for_notify(self, driver: Any, session_id: str) -> None:
680
+ """Replace ``driver.quit`` with a wrapper that also notifies the manager.
681
+
682
+ The notify fires at most once per registration: after the first quit
683
+ succeeds, subsequent quit() calls run the underlying quit but do
684
+ NOT post to /finished again. The underlying quit still runs every
685
+ call.
686
+
687
+ Raises AttributeError if the driver lacks a quit method.
688
+ """
689
+ original_quit = driver.quit
690
+ notified: dict[str, bool] = {"done": False}
691
+
692
+ def wrapped_quit(*args: Any, **kwargs: Any) -> Any:
693
+ try:
694
+ return original_quit(*args, **kwargs)
695
+ finally:
696
+ if not notified["done"]:
697
+ notified["done"] = True
698
+ self.notify_session_finished(session_id, suppress_errors=True)
699
+
700
+ driver.quit = wrapped_quit
650
701
 
651
702
  def complete_run(self, run_id: str) -> dict[str, Any]:
652
703
  resp = httpx.post(
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gridfleet-testkit"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "Supported pytest and run-orchestration helpers for GridFleet integrations"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import signal
4
- from typing import Any
4
+ from typing import Any, ClassVar
5
5
 
6
6
  import httpx
7
7
  import pytest
@@ -806,19 +806,19 @@ def test_register_session_from_driver_extracts_gridfleet_capabilities(monkeypatc
806
806
 
807
807
  monkeypatch.setattr(GridFleetClient, "register_session", fake_register_session)
808
808
 
809
- driver = type(
810
- "Driver",
811
- (),
812
- {
813
- "session_id": "sess-1",
814
- "capabilities": {
815
- "appium:gridfleet:deviceId": "dev-1",
816
- "appium:udid": "SERIAL123",
817
- "appium:platform": "android_mobile",
818
- "platformName": "Android",
819
- },
820
- },
821
- )()
809
+ class FakeDriver:
810
+ session_id = "sess-1"
811
+ capabilities: ClassVar[dict[str, object]] = {
812
+ "appium:gridfleet:deviceId": "dev-1",
813
+ "appium:udid": "SERIAL123",
814
+ "appium:platform": "android_mobile",
815
+ "platformName": "Android",
816
+ }
817
+
818
+ def quit(self) -> None:
819
+ return None
820
+
821
+ driver = FakeDriver()
822
822
  client = GridFleetClient("http://manager/api")
823
823
 
824
824
  assert client.register_session_from_driver(driver, test_name="test_login", run_id="run-1") == {"ok": True}
@@ -1643,3 +1643,109 @@ def test_register_run_cleanup_idempotent_under_explicit_double_call(monkeypatch)
1643
1643
  cleanup_fn() # second invocation must be a no-op
1644
1644
 
1645
1645
  assert client.calls == ["complete:run-double"]
1646
+
1647
+
1648
+ # --- Task 7: notify_session_finished and driver.quit wrap ---
1649
+
1650
+
1651
+ def test_notify_session_finished_calls_endpoint(monkeypatch):
1652
+ """notify_session_finished posts to /api/sessions/{id}/finished."""
1653
+ recorded: dict[str, Any] = {}
1654
+
1655
+ def fake_post(
1656
+ url: str,
1657
+ *,
1658
+ timeout: int,
1659
+ auth: Any = None,
1660
+ ) -> DummyResponse:
1661
+ recorded["url"] = url
1662
+ recorded["timeout"] = timeout
1663
+ return DummyResponse(None, status_code=204)
1664
+
1665
+ monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
1666
+
1667
+ client = GridFleetClient("http://localhost:8000/api")
1668
+ client.notify_session_finished("abc-123", suppress_errors=False)
1669
+
1670
+ assert recorded["url"] == "http://localhost:8000/api/sessions/abc-123/finished"
1671
+ assert recorded["timeout"] == 5
1672
+
1673
+
1674
+ def test_register_session_from_driver_wraps_quit_to_notify(monkeypatch):
1675
+ """After register_session_from_driver, driver.quit() also fires notify."""
1676
+ post_calls: list[str] = []
1677
+ quit_called = {"n": 0}
1678
+
1679
+ def fake_post(
1680
+ url: str,
1681
+ *,
1682
+ json: dict[str, Any] | None = None,
1683
+ timeout: int = 5,
1684
+ auth: Any = None,
1685
+ ) -> DummyResponse:
1686
+ post_calls.append(url)
1687
+ if url.endswith("/sessions"):
1688
+ return DummyResponse({"id": "session-xyz"}, status_code=201)
1689
+ if url.endswith("/finished"):
1690
+ return DummyResponse(None, status_code=204)
1691
+ return DummyResponse({})
1692
+
1693
+ monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
1694
+
1695
+ class FakeDriver:
1696
+ session_id = "session-xyz"
1697
+ capabilities: ClassVar[dict[str, object]] = {}
1698
+
1699
+ def quit(self) -> None:
1700
+ quit_called["n"] += 1
1701
+
1702
+ driver = FakeDriver()
1703
+ client = GridFleetClient("http://localhost:8000/api")
1704
+ client.register_session_from_driver(driver, test_name="t", suppress_errors=False)
1705
+
1706
+ driver.quit()
1707
+ assert quit_called["n"] == 1
1708
+ finished_calls = [u for u in post_calls if u.endswith("/finished")]
1709
+ assert len(finished_calls) == 1
1710
+
1711
+
1712
+ def test_register_session_from_driver_quit_wrap_is_idempotent(monkeypatch):
1713
+ """Calling driver.quit() twice does not fire two notify requests, but underlying quit runs both times."""
1714
+ post_calls: list[str] = []
1715
+
1716
+ def fake_post(
1717
+ url: str,
1718
+ *,
1719
+ json: dict[str, Any] | None = None,
1720
+ timeout: int = 5,
1721
+ auth: Any = None,
1722
+ ) -> DummyResponse:
1723
+ post_calls.append(url)
1724
+ if url.endswith("/sessions"):
1725
+ return DummyResponse({"id": "session-xyz"}, status_code=201)
1726
+ if url.endswith("/finished"):
1727
+ return DummyResponse(None, status_code=204)
1728
+ return DummyResponse({})
1729
+
1730
+ monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
1731
+
1732
+ class FakeDriver:
1733
+ session_id = "session-xyz"
1734
+ capabilities: ClassVar[dict[str, object]] = {}
1735
+ quit_count = 0
1736
+
1737
+ def quit(self) -> None:
1738
+ type(self).quit_count += 1
1739
+
1740
+ driver = FakeDriver()
1741
+ client = GridFleetClient("http://localhost:8000/api")
1742
+ client.register_session_from_driver(driver, test_name="t", suppress_errors=False)
1743
+
1744
+ driver.quit()
1745
+ driver.quit() # second call: must NOT post /finished again, but MUST run quit
1746
+
1747
+ # /finished posted exactly once
1748
+ finished_calls = [u for u in post_calls if u.endswith("/finished")]
1749
+ assert len(finished_calls) == 1
1750
+ # underlying quit ran twice
1751
+ assert FakeDriver.quit_count == 2
@@ -136,7 +136,7 @@ wheels = [
136
136
 
137
137
  [[package]]
138
138
  name = "gridfleet-testkit"
139
- version = "0.5.0"
139
+ version = "0.6.0"
140
140
  source = { editable = "." }
141
141
  dependencies = [
142
142
  { name = "httpx" },