godot-e2e 1.0.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.
godot_e2e/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """godot-e2e: Out-of-process E2E testing tool for Godot."""
2
+
3
+ from .commands import GodotE2E
4
+ from .types import (
5
+ Vector2,
6
+ Vector2i,
7
+ Vector3,
8
+ Vector3i,
9
+ Rect2,
10
+ Rect2i,
11
+ Color,
12
+ Transform2D,
13
+ NodePath,
14
+ deserialize,
15
+ serialize,
16
+ GodotE2EError,
17
+ NodeNotFoundError,
18
+ TimeoutError,
19
+ ConnectionLostError,
20
+ CommandError,
21
+ )
22
+ from .client import GodotClient
23
+ from .launcher import GodotLauncher
24
+
25
+ __version__ = "1.0.0"
26
+
27
+ __all__ = [
28
+ "GodotE2E",
29
+ "Vector2",
30
+ "Vector2i",
31
+ "Vector3",
32
+ "Vector3i",
33
+ "Rect2",
34
+ "Rect2i",
35
+ "Color",
36
+ "Transform2D",
37
+ "NodePath",
38
+ "deserialize",
39
+ "serialize",
40
+ "GodotE2EError",
41
+ "NodeNotFoundError",
42
+ "TimeoutError",
43
+ "ConnectionLostError",
44
+ "CommandError",
45
+ "GodotClient",
46
+ "GodotLauncher",
47
+ ]
godot_e2e/cli.py ADDED
@@ -0,0 +1,10 @@
1
+ """CLI entry point for godot-e2e (P2 scope -- minimal stub)."""
2
+
3
+
4
+ def main():
5
+ print("godot-e2e REPL is not yet implemented (P2 scope).")
6
+ print("Use pytest to run E2E tests instead.")
7
+
8
+
9
+ if __name__ == "__main__":
10
+ main()
godot_e2e/client.py ADDED
@@ -0,0 +1,128 @@
1
+ """TCP client with length-prefix framing for communicating with Godot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import socket
7
+ import struct
8
+ import threading
9
+ from typing import Any, Dict
10
+
11
+ from .types import (
12
+ CommandError,
13
+ ConnectionLostError,
14
+ NodeNotFoundError,
15
+ )
16
+
17
+
18
+ class GodotClient:
19
+ """Blocking TCP client that speaks the godot-e2e wire protocol.
20
+
21
+ The wire format is simple length-prefixed JSON:
22
+ [4-byte big-endian uint32 payload length][UTF-8 JSON payload]
23
+ """
24
+
25
+ def __init__(self, host: str = "127.0.0.1", port: int = 6008) -> None:
26
+ self.host = host
27
+ self.port = port
28
+ self._sock: socket.socket | None = None
29
+ self._recv_buffer: bytes = b""
30
+ self._next_id: int = 1
31
+ self._lock = threading.Lock()
32
+
33
+ # ------------------------------------------------------------------
34
+ # Connection lifecycle
35
+ # ------------------------------------------------------------------
36
+
37
+ def connect(self, timeout: float = 10.0) -> None:
38
+ """Connect to Godot's AutomationServer."""
39
+ # Close any previous socket to avoid leaking on retry.
40
+ if self._sock is not None:
41
+ try:
42
+ self._sock.close()
43
+ except Exception:
44
+ pass
45
+ self._recv_buffer = b""
46
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
47
+ self._sock.settimeout(timeout)
48
+ self._sock.connect((self.host, self.port))
49
+
50
+ def close(self) -> None:
51
+ """Close the TCP connection."""
52
+ if self._sock is not None:
53
+ try:
54
+ self._sock.close()
55
+ except Exception:
56
+ pass
57
+ self._sock = None
58
+ self._recv_buffer = b""
59
+
60
+ # ------------------------------------------------------------------
61
+ # Command API
62
+ # ------------------------------------------------------------------
63
+
64
+ def send_command(self, action: str, **params: Any) -> Dict[str, Any]:
65
+ """Send a command and block until the matching response arrives.
66
+
67
+ Returns the parsed response dict with values deserialized into
68
+ Python types (Vector2, Color, etc.) where applicable.
69
+
70
+ Raises:
71
+ NodeNotFoundError: if the server reports a missing node.
72
+ CommandError: for any other server-side error.
73
+ ConnectionLostError: if the connection drops.
74
+ """
75
+ with self._lock:
76
+ cmd_id = self._next_id
77
+ self._next_id += 1
78
+
79
+ msg: Dict[str, Any] = {"id": cmd_id, "action": action, **params}
80
+ payload = json.dumps(msg).encode("utf-8")
81
+ header = struct.pack(">I", len(payload))
82
+
83
+ try:
84
+ self._sock.sendall(header + payload)
85
+ except (OSError, AttributeError) as exc:
86
+ raise ConnectionLostError(f"Failed to send command: {exc}") from exc
87
+
88
+ response = self._read_response()
89
+
90
+ if "error" in response:
91
+ error_code = response["error"]
92
+ error_msg = response.get("message", error_code)
93
+ if "not found" in error_msg.lower() or "not found" in error_code.lower():
94
+ raise NodeNotFoundError(error_msg)
95
+ raise CommandError(error_msg)
96
+
97
+ return response
98
+
99
+ def hello(self, token: str) -> Dict[str, Any]:
100
+ """Send the handshake. Must be the first command after connecting."""
101
+ return self.send_command("hello", token=token, protocol_version=1)
102
+
103
+ # ------------------------------------------------------------------
104
+ # Internal helpers
105
+ # ------------------------------------------------------------------
106
+
107
+ def _read_response(self) -> Dict[str, Any]:
108
+ """Read one length-prefixed JSON message from the socket."""
109
+ while True:
110
+ # Try to extract a complete message from the buffer.
111
+ if len(self._recv_buffer) >= 4:
112
+ payload_len = struct.unpack(">I", self._recv_buffer[:4])[0]
113
+ total = 4 + payload_len
114
+ if len(self._recv_buffer) >= total:
115
+ payload = self._recv_buffer[4:total]
116
+ self._recv_buffer = self._recv_buffer[total:]
117
+ return json.loads(payload.decode("utf-8"))
118
+
119
+ # Need more data from the network.
120
+ try:
121
+ chunk = self._sock.recv(4096)
122
+ if not chunk:
123
+ raise ConnectionLostError("Connection closed by Godot")
124
+ self._recv_buffer += chunk
125
+ except socket.timeout as exc:
126
+ raise ConnectionLostError(f"Connection timed out: {exc}") from exc
127
+ except OSError as exc:
128
+ raise ConnectionLostError(f"Connection lost: {exc}") from exc
godot_e2e/commands.py ADDED
@@ -0,0 +1,234 @@
1
+ """High-level E2E command API for Godot."""
2
+
3
+ from .client import GodotClient
4
+ from .types import (
5
+ serialize, deserialize, TimeoutError, NodeNotFoundError,
6
+ ConnectionLostError, CommandError
7
+ )
8
+ import time
9
+
10
+
11
+ class GodotE2E:
12
+ """High-level E2E testing interface for Godot.
13
+
14
+ Usage:
15
+ with GodotE2E.launch("./my_project") as game:
16
+ game.wait_for_node("/root/Main")
17
+ pos = game.get_property("/root/Main/Player", "position")
18
+ """
19
+
20
+ def __init__(self, client: GodotClient, launcher=None):
21
+ self._client = client
22
+ self._launcher = launcher
23
+
24
+ @classmethod
25
+ def launch(cls, project_path: str, godot_path: str = None,
26
+ port: int = 0, timeout: float = 10.0, extra_args: list = None):
27
+ """Launch Godot and return a connected GodotE2E instance.
28
+ Returns a context manager."""
29
+ from .launcher import GodotLauncher
30
+ launcher = GodotLauncher()
31
+ client = launcher.launch(project_path, godot_path, port, timeout, extra_args)
32
+ return cls(client, launcher)
33
+
34
+ @classmethod
35
+ def connect(cls, host: str = "127.0.0.1", port: int = 6008, token: str = ""):
36
+ """Connect to an already-running Godot instance."""
37
+ client = GodotClient(host, port)
38
+ client.connect()
39
+ client.hello(token)
40
+ return cls(client)
41
+
42
+ def __enter__(self):
43
+ return self
44
+
45
+ def __exit__(self, *args):
46
+ self.close()
47
+
48
+ def close(self):
49
+ if self._launcher:
50
+ self._launcher.kill()
51
+ elif self._client:
52
+ self._client.close()
53
+
54
+ # --- Node Operations (F2) ---
55
+
56
+ def node_exists(self, path: str) -> bool:
57
+ resp = self._client.send_command("node_exists", path=path)
58
+ return resp.get("exists", False)
59
+
60
+ def get_property(self, path: str, property: str):
61
+ resp = self._client.send_command("get_property", path=path, property=property)
62
+ return deserialize(resp["result"])
63
+
64
+ def set_property(self, path: str, property: str, value):
65
+ self._client.send_command(
66
+ "set_property", path=path, property=property, value=serialize(value)
67
+ )
68
+
69
+ def call(self, path: str, method: str, args: list = None):
70
+ resp = self._client.send_command(
71
+ "call_method", path=path, method=method,
72
+ args=[serialize(a) for a in (args or [])]
73
+ )
74
+ return deserialize(resp.get("result"))
75
+
76
+ def find_by_group(self, group: str) -> list:
77
+ resp = self._client.send_command("find_by_group", group=group)
78
+ return resp.get("nodes", [])
79
+
80
+ def query_nodes(self, pattern: str = "", group: str = "") -> list:
81
+ resp = self._client.send_command("query_nodes", pattern=pattern, group=group)
82
+ return resp.get("nodes", [])
83
+
84
+ def get_tree(self, path: str = "/root", depth: int = 4) -> dict:
85
+ resp = self._client.send_command("get_tree", path=path, depth=depth)
86
+ return resp.get("tree", {})
87
+
88
+ def batch(self, commands: list) -> list:
89
+ """Execute multiple commands in one round-trip.
90
+
91
+ Each command is either a dict with an "action" key, or a tuple/list of
92
+ (action, params_dict).
93
+
94
+ Example::
95
+
96
+ results = game.batch([
97
+ ("get_property", {"path": "/root/Player", "property": "health"}),
98
+ {"action": "node_exists", "path": "/root/Enemy"},
99
+ ])
100
+ """
101
+ cmd_list = []
102
+ for cmd in commands:
103
+ if isinstance(cmd, dict):
104
+ cmd_list.append(cmd)
105
+ elif isinstance(cmd, (list, tuple)):
106
+ action = cmd[0]
107
+ params = cmd[1] if len(cmd) > 1 else {}
108
+ cmd_list.append({"action": action, **params})
109
+ resp = self._client.send_command("batch", commands=cmd_list)
110
+ results = resp.get("results", [])
111
+ return [
112
+ deserialize(r.get("result")) if "result" in r else r
113
+ for r in results
114
+ ]
115
+
116
+ # --- Input Simulation (F3) ---
117
+
118
+ def input_key(self, keycode: int, pressed: bool, physical: bool = False):
119
+ self._client.send_command(
120
+ "input_key", keycode=keycode, pressed=pressed, physical=physical
121
+ )
122
+
123
+ def input_action(self, action_name: str, pressed: bool, strength: float = 1.0):
124
+ self._client.send_command(
125
+ "input_action", action_name=action_name, pressed=pressed, strength=strength
126
+ )
127
+
128
+ def input_mouse_button(
129
+ self, x: float, y: float, button: int = 1, pressed: bool = True
130
+ ):
131
+ self._client.send_command(
132
+ "input_mouse_button", x=x, y=y, button=button, pressed=pressed
133
+ )
134
+
135
+ def input_mouse_motion(
136
+ self, x: float, y: float, relative_x: float = 0, relative_y: float = 0
137
+ ):
138
+ self._client.send_command(
139
+ "input_mouse_motion", x=x, y=y,
140
+ relative_x=relative_x, relative_y=relative_y
141
+ )
142
+
143
+ # --- High-Level Helpers (F6) ---
144
+
145
+ def press_key(self, keycode: int):
146
+ """Press and release a key."""
147
+ self.input_key(keycode, True)
148
+ self.input_key(keycode, False)
149
+
150
+ def press_action(self, action_name: str, strength: float = 1.0):
151
+ """Press and release an action."""
152
+ self.input_action(action_name, True, strength)
153
+ self.input_action(action_name, False)
154
+
155
+ def click(self, x: float, y: float, button: int = 1):
156
+ """Click at screen position."""
157
+ self.input_mouse_button(x, y, button, True)
158
+ self.input_mouse_button(x, y, button, False)
159
+
160
+ def click_node(self, path: str):
161
+ """Click at a node's screen position."""
162
+ self._client.send_command("click_node", path=path)
163
+
164
+ # --- Frame Synchronization (F4) ---
165
+
166
+ def wait_process_frames(self, count: int = 1):
167
+ self._client.send_command("wait_process_frames", count=count)
168
+
169
+ def wait_physics_frames(self, count: int = 1):
170
+ self._client.send_command("wait_physics_frames", count=count)
171
+
172
+ def wait_seconds(self, seconds: float):
173
+ self._client.send_command("wait_seconds", seconds=seconds)
174
+
175
+ # --- Synchronization (F6/F9) ---
176
+
177
+ def wait_for_node(self, path: str, timeout: float = 5.0):
178
+ """Wait until a node exists in the scene tree.
179
+
180
+ Raises TimeoutError with a scene tree dump if the timeout is exceeded.
181
+ """
182
+ try:
183
+ self._client.send_command("wait_for_node", path=path, timeout=timeout)
184
+ except CommandError as e:
185
+ if "timeout" in str(e).lower():
186
+ tree = None
187
+ try:
188
+ tree = self.get_tree()
189
+ except Exception:
190
+ pass
191
+ raise TimeoutError(
192
+ f"Timed out waiting for node '{path}' after {timeout}s",
193
+ scene_tree=tree,
194
+ ) from e
195
+ raise
196
+
197
+ def wait_for_signal(self, path: str, signal_name: str, timeout: float = 5.0):
198
+ resp = self._client.send_command(
199
+ "wait_for_signal", path=path, signal_name=signal_name, timeout=timeout
200
+ )
201
+ return resp.get("args", [])
202
+
203
+ def wait_for_property(self, path: str, property: str, value, timeout: float = 5.0):
204
+ self._client.send_command(
205
+ "wait_for_property", path=path, property=property,
206
+ value=serialize(value), timeout=timeout,
207
+ )
208
+
209
+ # --- Scene Management (F11) ---
210
+
211
+ def get_scene(self) -> str:
212
+ resp = self._client.send_command("get_scene")
213
+ return resp.get("scene", "")
214
+
215
+ def change_scene(self, scene_path: str):
216
+ self._client.send_command("change_scene", scene_path=scene_path)
217
+
218
+ def reload_scene(self):
219
+ self._client.send_command("reload_scene")
220
+
221
+ # --- Screenshot (F10) ---
222
+
223
+ def screenshot(self, save_path: str = "") -> str:
224
+ """Capture a screenshot. Returns the absolute path to the saved PNG."""
225
+ resp = self._client.send_command("screenshot", save_path=save_path)
226
+ return resp.get("path", "")
227
+
228
+ # --- Misc ---
229
+
230
+ def quit(self, exit_code: int = 0):
231
+ try:
232
+ self._client.send_command("quit", exit_code=exit_code)
233
+ except ConnectionLostError:
234
+ pass # Expected — Godot exits
godot_e2e/fixtures.py ADDED
@@ -0,0 +1,130 @@
1
+ """pytest fixtures for godot-e2e."""
2
+
3
+ import pytest
4
+ import os
5
+ from .commands import GodotE2E
6
+
7
+
8
+ def pytest_configure(config):
9
+ """Register the screenshot-on-failure plugin (idempotent)."""
10
+ if not config.pluginmanager.has_plugin("godot_e2e_screenshot"):
11
+ config.pluginmanager.register(ScreenshotOnFailure(), "godot_e2e_screenshot")
12
+
13
+
14
+ class ScreenshotOnFailure:
15
+ """pytest plugin that captures screenshots on test failure."""
16
+
17
+ @pytest.hookimpl(tryfirst=True, hookwrapper=True)
18
+ def pytest_runtest_makereport(self, item, call):
19
+ outcome = yield
20
+ report = outcome.get_result()
21
+ # Stash the call report on the test item so fixtures can access it.
22
+ setattr(item, f"rep_{report.when}", report)
23
+
24
+
25
+ @pytest.fixture(scope="module")
26
+ def _game_instance(request):
27
+ """Module-scoped: one Godot process per test module."""
28
+ project_path = _get_project_path(request)
29
+ godot_path = _get_godot_path(request)
30
+
31
+ with GodotE2E.launch(project_path, godot_path=godot_path) as game:
32
+ yield game
33
+
34
+
35
+ @pytest.fixture(scope="function")
36
+ def game(_game_instance, request):
37
+ """Function-scoped fixture: reload the scene between tests and capture a
38
+ screenshot on failure.
39
+
40
+ Requires a module-scoped ``_game_instance`` to be in scope (one Godot
41
+ process shared across all tests in the same module).
42
+ """
43
+ _game_instance.reload_scene()
44
+ _game_instance.wait_for_node("/root", timeout=5.0)
45
+ yield _game_instance
46
+
47
+ # Screenshot on failure
48
+ if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
49
+ _take_failure_screenshot(_game_instance, request.node.name)
50
+
51
+
52
+ @pytest.fixture(scope="function")
53
+ def game_fresh(request):
54
+ """Function-scoped fixture: fresh Godot process per test (maximum isolation).
55
+
56
+ Use this when tests must not share any Godot state at all.
57
+ """
58
+ project_path = _get_project_path(request)
59
+ godot_path = _get_godot_path(request)
60
+
61
+ with GodotE2E.launch(project_path, godot_path=godot_path) as game:
62
+ yield game
63
+
64
+ # Screenshot on failure
65
+ if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
66
+ _take_failure_screenshot(game, request.node.name)
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Internal helpers
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def _take_failure_screenshot(game: GodotE2E, test_name: str):
74
+ """Capture a screenshot on test failure and save it to ``test_output/``."""
75
+ try:
76
+ os.makedirs("test_output", exist_ok=True)
77
+ safe_name = test_name.replace("/", "_").replace("\\", "_")
78
+ path = os.path.join("test_output", f"{safe_name}_failure.png")
79
+ game.screenshot(os.path.abspath(path))
80
+ print(f"\n[godot-e2e] Failure screenshot saved: {path}")
81
+ except Exception as e:
82
+ print(f"\n[godot-e2e] Failed to capture screenshot: {e}")
83
+
84
+
85
+ def _get_project_path(request) -> str:
86
+ """Resolve the Godot project path from multiple sources (in priority order):
87
+
88
+ 1. ``@pytest.mark.godot_project("path")`` marker on the test/module.
89
+ 2. ``godot_e2e_project_path`` key in ``pytest.ini`` / ``pyproject.toml``.
90
+ 3. ``GODOT_E2E_PROJECT_PATH`` environment variable.
91
+ 4. Auto-detection: searches ``./godot_project``, ``../godot_project``, and
92
+ ``.`` for a ``project.godot`` file.
93
+ """
94
+ # 1. Marker
95
+ marker = request.node.get_closest_marker("godot_project")
96
+ if marker:
97
+ return marker.args[0]
98
+
99
+ # 2. pytest config key
100
+ try:
101
+ config_path = request.config.getini("godot_e2e_project_path")
102
+ if config_path:
103
+ return config_path
104
+ except (ValueError, KeyError):
105
+ pass
106
+
107
+ # 3. Environment variable
108
+ env_path = os.environ.get("GODOT_E2E_PROJECT_PATH", "")
109
+ if env_path:
110
+ return env_path
111
+
112
+ # 4. Auto-detection
113
+ for candidate in ["./godot_project", "../godot_project", "."]:
114
+ if os.path.isfile(os.path.join(candidate, "project.godot")):
115
+ return candidate
116
+
117
+ raise FileNotFoundError(
118
+ "Could not find a Godot project. Set the GODOT_E2E_PROJECT_PATH "
119
+ "environment variable, add 'godot_e2e_project_path' to your pytest "
120
+ "configuration, or use @pytest.mark.godot_project('path/to/project') "
121
+ "on your test class or module."
122
+ )
123
+
124
+
125
+ def _get_godot_path(request) -> str | None:
126
+ """Return the path to the Godot executable, or None to use PATH lookup.
127
+
128
+ Reads from the ``GODOT_PATH`` environment variable.
129
+ """
130
+ return os.environ.get("GODOT_PATH") or None