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 +47 -0
- godot_e2e/cli.py +10 -0
- godot_e2e/client.py +128 -0
- godot_e2e/commands.py +234 -0
- godot_e2e/fixtures.py +130 -0
- godot_e2e/launcher.py +163 -0
- godot_e2e/types.py +176 -0
- godot_e2e-1.0.0.dist-info/METADATA +310 -0
- godot_e2e-1.0.0.dist-info/RECORD +14 -0
- godot_e2e-1.0.0.dist-info/WHEEL +5 -0
- godot_e2e-1.0.0.dist-info/entry_points.txt +2 -0
- godot_e2e-1.0.0.dist-info/licenses/LICENSE +201 -0
- godot_e2e-1.0.0.dist-info/licenses/NOTICE +14 -0
- godot_e2e-1.0.0.dist-info/top_level.txt +1 -0
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
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
|