godot-cli-control 0.2.7__tar.gz → 0.2.9__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.
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/PKG-INFO +1 -1
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/README.md +1 -1
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/game_bridge.gd +20 -4
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/input_simulation_api.gd +8 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +50 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +16 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/_version.py +2 -2
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/cli.py +63 -4
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/client.py +23 -10
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/templates/skill/SKILL.md +6 -2
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/test_cli.py +226 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/test_client.py +83 -1
- godot_cli_control-0.2.9/python/tests/test_e2e_input.py +175 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/.gitignore +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/LICENSE +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/CHANGELOG.md +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/LICENSE +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/error_codes.gd +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/low_level_api.gd +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/plugin.cfg +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/plugin.gd +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/plugin.gd.uid +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/run_gut.sh +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/pyproject.toml +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/README.md +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/__init__.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/__main__.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/_duration.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/bridge.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/daemon.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/init_cmd.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/pytest_plugin.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/registry.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/runner.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/skills_install.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/templates/__init__.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/templates/skill/__init__.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/__init__.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/test_bridge.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/test_cli_helpers.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/test_daemon.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/test_duration.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/test_init.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/test_pytest_plugin.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/test_registry.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/test_runner.py +0 -0
- {godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/tests/test_skills_install.py +0 -0
|
@@ -114,7 +114,7 @@ Three numeric ranges share `error.code`; they never overlap, so a single field i
|
|
|
114
114
|
| `1006` | server | Resource transiently unavailable (e.g. screenshot during scene transition). Rare under normal use — GameBridge waits for viewport first-frame before listening, and `screenshot` retries internally. Safe to retry if you do hit it. |
|
|
115
115
|
| `-32600` | server | Malformed JSON-RPC request |
|
|
116
116
|
| `-32601` | server | Unknown method name |
|
|
117
|
-
| `-32602` | server | Invalid params (incl. blocked methods/properties from the security blacklist,
|
|
117
|
+
| `-32602` | server | Invalid params (incl. blocked methods/properties from the security blacklist, `set` value-type mismatch — e.g. `Vector2` property given an array of wrong length / non-numeric elements, or `hold` given `duration ≤ 0` — use `press` for an indefinite hold) |
|
|
118
118
|
| `-1001` | client | Connection failure (daemon not running, port wrong, proxy hijacking localhost) |
|
|
119
119
|
| `-1002` | client | Timeout waiting for response |
|
|
120
120
|
| `-1003` | client | CLI usage error (combo missing steps, malformed `--steps-json`, …) |
|
{godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/game_bridge.gd
RENAMED
|
@@ -143,10 +143,7 @@ func _poll_active_peer() -> void:
|
|
|
143
143
|
_active_peer.poll()
|
|
144
144
|
var state: WebSocketPeer.State = _active_peer.get_ready_state()
|
|
145
145
|
if state == WebSocketPeer.STATE_CLOSED:
|
|
146
|
-
|
|
147
|
-
_input_sim_api.release_all()
|
|
148
|
-
_active_peer = null
|
|
149
|
-
_active_stream = null
|
|
146
|
+
_handle_disconnect(_active_peer.get_close_code())
|
|
150
147
|
return
|
|
151
148
|
if state != WebSocketPeer.STATE_OPEN:
|
|
152
149
|
return
|
|
@@ -156,6 +153,25 @@ func _poll_active_peer() -> void:
|
|
|
156
153
|
_handle_message(message)
|
|
157
154
|
|
|
158
155
|
|
|
156
|
+
func _handle_disconnect(close_code: int) -> void:
|
|
157
|
+
print("GameBridge: Client disconnected (close_code=%d)" % close_code)
|
|
158
|
+
# 区分「命令正常结束」与「异常掉线」,决定是否 release_all:
|
|
159
|
+
# - CLI 每条子命令都是独立连接、跑完即干净关闭(WebSocket close frame,
|
|
160
|
+
# code 1000)。此时**不** release_all —— 否则 `hold <dur>` 的定时器还没
|
|
161
|
+
# 倒计时就被清掉(只生效一帧),sticky `press` 也无法跨命令存活。持有的
|
|
162
|
+
# 输入靠各自机制收尾:hold/tap/combo 的 advance_timers 定时器自然结束,
|
|
163
|
+
# sticky press 持续到显式 release / release-all。
|
|
164
|
+
# - 客户端崩溃 / 被 kill / 网络断开时不会发 close frame,get_close_code()
|
|
165
|
+
# 返回 -1(< 0)。此时 release_all 兜底,避免卡死键 + 跨会话脏状态残留。
|
|
166
|
+
# (daemon 启动期的端口探活连接也可能是 -1,但那时没有任何持有输入,
|
|
167
|
+
# release_all 无副作用。)
|
|
168
|
+
# pytest 的 bridge fixture 仍在 teardown 自己调 release_all() 做清理。
|
|
169
|
+
if close_code < 0:
|
|
170
|
+
_input_sim_api.release_all()
|
|
171
|
+
_active_peer = null
|
|
172
|
+
_active_stream = null
|
|
173
|
+
|
|
174
|
+
|
|
159
175
|
func _register_methods() -> void:
|
|
160
176
|
# 低层 API(同步)
|
|
161
177
|
_methods["click"] = {"callable": _low_level_api.handle_click, "kind": "sync"}
|
|
@@ -113,6 +113,14 @@ func handle_hold(params: Dictionary) -> Dictionary:
|
|
|
113
113
|
if not InputMap.has_action(action):
|
|
114
114
|
return _err(CliControlErrorCodes.METHOD_NOT_FOUND, "Unknown action: %s" % action)
|
|
115
115
|
var duration: float = params.get("duration", 0.0) as float
|
|
116
|
+
# duration <= 0 是无意义的「按住 0 秒」:advance_timers 下一帧就释放 → 只生效
|
|
117
|
+
# 一帧。无限按住请用 press(sticky)。CLI preflight 也会拦,这里是防御纵深,
|
|
118
|
+
# 挡住绕过 CLI 的直连 RPC。
|
|
119
|
+
if duration <= 0.0:
|
|
120
|
+
return _err(
|
|
121
|
+
CliControlErrorCodes.INVALID_PARAMS,
|
|
122
|
+
"hold duration must be > 0 (got %s); use press for an indefinite hold" % duration,
|
|
123
|
+
)
|
|
116
124
|
_do_press(action)
|
|
117
125
|
_held_actions[action] = duration
|
|
118
126
|
return {"success": true}
|
|
@@ -70,10 +70,17 @@ class StubInputSimulationApi:
|
|
|
70
70
|
# _register_methods 之前测试会手动调一次。
|
|
71
71
|
combo_callback = send_response
|
|
72
72
|
|
|
73
|
+
# 断连测试用的 release_all 调用计数(不 override 行为,仅记账后走父类)
|
|
74
|
+
var release_all_calls: int = 0
|
|
75
|
+
|
|
73
76
|
func handle_action_press(params: Dictionary) -> Dictionary:
|
|
74
77
|
press_calls.append(params)
|
|
75
78
|
return press_return
|
|
76
79
|
|
|
80
|
+
func release_all() -> void:
|
|
81
|
+
release_all_calls += 1
|
|
82
|
+
super()
|
|
83
|
+
|
|
77
84
|
func handle_combo(params: Dictionary, request_id: String) -> void:
|
|
78
85
|
# 不立刻回响 —— 把 (params, request_id) 记下来;测试通过 finish_combo
|
|
79
86
|
# 显式触发 callback,模拟真实 combo 完成路径。
|
|
@@ -122,6 +129,49 @@ func _last_frame() -> Dictionary:
|
|
|
122
129
|
return _bridge.captured_frames[-1]
|
|
123
130
|
|
|
124
131
|
|
|
132
|
+
# ── 断连按 close code 区分清/不清 ───────────────────────────────────
|
|
133
|
+
# 回归:CLI 每条子命令都是独立连接、跑完即「干净关闭」(close frame,code
|
|
134
|
+
# 1000)。干净关闭不能 release_all,否则 `hold <dur>` 定时器没倒计时就被清掉
|
|
135
|
+
# (只生效一帧),sticky `press` 也无法跨命令存活。只有「异常掉线」(崩溃 /
|
|
136
|
+
# kill / 网络断,get_close_code() == -1)才 release_all 兜底卡死键。
|
|
137
|
+
# handle_hold 是真实逻辑(桩未 override),用它验证 held 状态。
|
|
138
|
+
# 用足够长的 duration,避免测试期间 _process 的 advance_timers 提前释放。
|
|
139
|
+
|
|
140
|
+
func test_clean_disconnect_preserves_inputs() -> void:
|
|
141
|
+
var hold_action := "__test_clean_disc__"
|
|
142
|
+
if not InputMap.has_action(hold_action):
|
|
143
|
+
InputMap.add_action(hold_action)
|
|
144
|
+
_input.handle_hold({"action": hold_action, "duration": 999.0})
|
|
145
|
+
assert_true(hold_action in _input.get_pressed_actions(), "前置:hold 应在持有列表")
|
|
146
|
+
|
|
147
|
+
# 1000 = 正常 WebSocket close frame(CLI 命令跑完)
|
|
148
|
+
_bridge._handle_disconnect(1000)
|
|
149
|
+
|
|
150
|
+
assert_eq(_input.release_all_calls, 0, "干净关闭不应调用 release_all(hold/press 须跨命令存活)")
|
|
151
|
+
assert_true(hold_action in _input.get_pressed_actions(), "干净关闭后 hold 应仍存活")
|
|
152
|
+
assert_eq(_bridge._active_peer, null, "断连应清掉 _active_peer")
|
|
153
|
+
|
|
154
|
+
_input.release_all()
|
|
155
|
+
InputMap.erase_action(hold_action)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
func test_abnormal_disconnect_releases_inputs() -> void:
|
|
159
|
+
var hold_action := "__test_abnormal_disc__"
|
|
160
|
+
if not InputMap.has_action(hold_action):
|
|
161
|
+
InputMap.add_action(hold_action)
|
|
162
|
+
_input.handle_hold({"action": hold_action, "duration": 999.0})
|
|
163
|
+
assert_true(hold_action in _input.get_pressed_actions(), "前置:hold 应在持有列表")
|
|
164
|
+
|
|
165
|
+
# -1 = 异常掉线(无 close frame:崩溃 / kill / 网络断)
|
|
166
|
+
_bridge._handle_disconnect(-1)
|
|
167
|
+
|
|
168
|
+
assert_eq(_input.release_all_calls, 1, "异常掉线应调用 release_all 兜底卡死键")
|
|
169
|
+
assert_false(hold_action in _input.get_pressed_actions(), "异常掉线后 hold 应被释放")
|
|
170
|
+
assert_eq(_bridge._active_peer, null, "断连应清掉 _active_peer")
|
|
171
|
+
|
|
172
|
+
InputMap.erase_action(hold_action)
|
|
173
|
+
|
|
174
|
+
|
|
125
175
|
# ── JSON / 协议层校验:-32600 ──────────────────────────────────────
|
|
126
176
|
|
|
127
177
|
func test_invalid_json_emits_minus_32600_with_empty_id() -> void:
|
|
@@ -83,6 +83,22 @@ func test_hold_unknown_action_returns_1003() -> void:
|
|
|
83
83
|
assert_false(_api.has_active_holds())
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
func test_hold_zero_duration_returns_invalid_params() -> void:
|
|
87
|
+
# duration <= 0 是无意义的「按住 0 秒」;防御纵深拦在服务端(CLI preflight 也拦)。
|
|
88
|
+
var result: Dictionary = _api.handle_hold({"action": "a", "duration": 0.0})
|
|
89
|
+
assert_has(result, "error")
|
|
90
|
+
assert_eq(int(result.error.code), -32602, "duration=0 应回 INVALID_PARAMS(-32602)")
|
|
91
|
+
assert_false(_api.has_active_holds(), "非法 duration 不应进 held 列表")
|
|
92
|
+
assert_false("a" in _api.get_pressed_actions())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
func test_hold_negative_duration_returns_invalid_params() -> void:
|
|
96
|
+
var result: Dictionary = _api.handle_hold({"action": "a", "duration": -1.5})
|
|
97
|
+
assert_has(result, "error")
|
|
98
|
+
assert_eq(int(result.error.code), -32602)
|
|
99
|
+
assert_false(_api.has_active_holds())
|
|
100
|
+
|
|
101
|
+
|
|
86
102
|
func test_combo_unknown_action_aborts_with_1003() -> void:
|
|
87
103
|
# combo step 引用未注册 action:必须 abort 整盘并通过 request_id 回 1003,
|
|
88
104
|
# 否则 _combo_active 卡 true 后续全 1004。
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.9'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 9)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -60,17 +60,26 @@ CLIENT_CODE_SCRIPT_ERROR = -1005 # `run <script>` 用户脚本抛出未捕获
|
|
|
60
60
|
CLIENT_CODE_INTERNAL = -1099 # 兜底:客户端内部异常(理论上不该到这里,但兜住契约)
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
def _resolve_headless(
|
|
63
|
+
def _resolve_headless(
|
|
64
|
+
ns: argparse.Namespace, *, force_gui_hint: bool = False
|
|
65
|
+
) -> bool:
|
|
64
66
|
"""决定本次 daemon start 是否走 --headless。
|
|
65
67
|
|
|
66
|
-
优先级:显式 --headless > 显式 --gui > stdout.isatty() 自动判。
|
|
68
|
+
优先级:显式 --headless > 显式 --gui > force_gui_hint > stdout.isatty() 自动判。
|
|
67
69
|
isatty=False(pipe / redirect / 非 TTY agent shell)默认 headless;
|
|
68
70
|
isatty=True(开发者交互终端)默认开窗。
|
|
71
|
+
|
|
72
|
+
``force_gui_hint``:调用方(``cmd_run``)静态检测脚本含 ``screenshot`` 时传
|
|
73
|
+
True,让没显式指定 ``--headless`` / ``--gui`` 的非 TTY 场景(subagent / pipe)
|
|
74
|
+
自动改走 GUI —— headless 下 dummy renderer 拿不到 viewport texture,screenshot
|
|
75
|
+
永远 1006 fail(issue #65)。
|
|
69
76
|
"""
|
|
70
77
|
if getattr(ns, "headless", False):
|
|
71
78
|
return True
|
|
72
79
|
if getattr(ns, "gui", False):
|
|
73
80
|
return False
|
|
81
|
+
if force_gui_hint:
|
|
82
|
+
return False
|
|
74
83
|
try:
|
|
75
84
|
return not sys.stdout.isatty()
|
|
76
85
|
except (OSError, ValueError):
|
|
@@ -79,6 +88,24 @@ def _resolve_headless(ns: argparse.Namespace) -> bool:
|
|
|
79
88
|
return True # 安全默认
|
|
80
89
|
|
|
81
90
|
|
|
91
|
+
def _script_likely_uses_screenshot(script_path: Path) -> bool:
|
|
92
|
+
"""启发式:脚本源码是否含 ``screenshot`` 子串。
|
|
93
|
+
|
|
94
|
+
保守策略 —— 误报(多开一次窗)成本远低于漏报(截图静默 1006 fail)。
|
|
95
|
+
简单子串足以覆盖 ``bridge.screenshot(...)`` / ``client.screenshot()`` /
|
|
96
|
+
间接 ``getattr(bridge, "screenshot")``;comment / docstring 命中也无所谓,
|
|
97
|
+
最坏只是 subagent 多看到一个窗口。
|
|
98
|
+
|
|
99
|
+
读不到(OSError / decode 失败)时返回 False —— 让 cmd_run 走原来的 isatty
|
|
100
|
+
默认,至少不会把"脚本不存在"的报错被本检测吞掉。
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
text = script_path.read_text(encoding="utf-8", errors="replace")
|
|
104
|
+
except OSError:
|
|
105
|
+
return False
|
|
106
|
+
return "screenshot" in text
|
|
107
|
+
|
|
108
|
+
|
|
82
109
|
# ── RpcSpec:声明一个 RPC 子命令 ──
|
|
83
110
|
|
|
84
111
|
|
|
@@ -148,6 +175,22 @@ def _resolve_args_for_call(ns: argparse.Namespace) -> list:
|
|
|
148
175
|
return [_parse_json_arg(a) for a in raw_args]
|
|
149
176
|
|
|
150
177
|
|
|
178
|
+
def _preflight_hold(ns: argparse.Namespace) -> None:
|
|
179
|
+
"""连 daemon 前校验 hold 的 duration:必须是 > 0 的数字。
|
|
180
|
+
|
|
181
|
+
duration <= 0 会让动作下一帧就释放(只生效一帧),是无意义用法;
|
|
182
|
+
要无限按住应该用 ``press``。preflight 拦住,避免 agent 干等连接重试。
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
duration = float(ns.duration)
|
|
186
|
+
except (TypeError, ValueError):
|
|
187
|
+
raise ValueError(f"hold: duration 必须是数字,收到 {ns.duration!r}")
|
|
188
|
+
if duration <= 0:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"hold: duration 必须 > 0(秒),收到 {duration};要无限按住请用 `press`"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
151
194
|
def _preflight_combo(ns: argparse.Namespace) -> None:
|
|
152
195
|
"""连 daemon 前用同一份解析逻辑校验 combo 输入;抛 ValueError 即用法错。
|
|
153
196
|
|
|
@@ -522,9 +565,10 @@ RPC_SPECS: tuple[RpcSpec, ...] = (
|
|
|
522
565
|
description="按住动作指定时长(秒),到点自动释放。",
|
|
523
566
|
positionals=(
|
|
524
567
|
Positional("action", None, "InputMap 动作名"),
|
|
525
|
-
Positional("duration", None, "
|
|
568
|
+
Positional("duration", None, "按住时长(秒,必须 > 0)"),
|
|
526
569
|
),
|
|
527
570
|
example="hold jump 1.5",
|
|
571
|
+
preflight=_preflight_hold,
|
|
528
572
|
text_formatter=lambda r: f"holding: {r}",
|
|
529
573
|
),
|
|
530
574
|
RpcSpec(
|
|
@@ -944,11 +988,17 @@ def cmd_run(ns: argparse.Namespace) -> int:
|
|
|
944
988
|
daemon = Daemon(Path.cwd())
|
|
945
989
|
auto_started = False
|
|
946
990
|
if not daemon.is_running():
|
|
991
|
+
# 静态检测脚本含 screenshot 时,非 TTY 默认从 headless 翻转到 GUI
|
|
992
|
+
# (issue #65)。显式 --headless / --gui / --no-gui-auto 都能 opt-out。
|
|
993
|
+
force_gui_hint = (
|
|
994
|
+
not getattr(ns, "no_gui_auto", False)
|
|
995
|
+
and _script_likely_uses_screenshot(script_path)
|
|
996
|
+
)
|
|
947
997
|
try:
|
|
948
998
|
daemon.start(
|
|
949
999
|
record=ns.record,
|
|
950
1000
|
movie_path=ns.movie_path,
|
|
951
|
-
headless=_resolve_headless(ns),
|
|
1001
|
+
headless=_resolve_headless(ns, force_gui_hint=force_gui_hint),
|
|
952
1002
|
fps=ns.fps,
|
|
953
1003
|
port=ns.port,
|
|
954
1004
|
idle_timeout=idle_seconds,
|
|
@@ -1491,6 +1541,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1491
1541
|
)
|
|
1492
1542
|
run_p.add_argument("script", help="用户脚本路径,需定义 run(bridge)")
|
|
1493
1543
|
_add_daemon_flags(run_p)
|
|
1544
|
+
run_p.add_argument(
|
|
1545
|
+
"--no-gui-auto",
|
|
1546
|
+
action="store_true",
|
|
1547
|
+
help=(
|
|
1548
|
+
"禁用脚本静态检测自动 GUI。默认含 screenshot 调用的脚本在非 TTY "
|
|
1549
|
+
"(subagent / pipe / CI)下也强制开窗 —— headless dummy renderer "
|
|
1550
|
+
"拿不到 viewport texture,截图会 1006 fail。"
|
|
1551
|
+
),
|
|
1552
|
+
)
|
|
1494
1553
|
_add_output_format_flags(run_p)
|
|
1495
1554
|
|
|
1496
1555
|
# init:一键接入
|
|
@@ -14,6 +14,17 @@ logger = logging.getLogger(__name__)
|
|
|
14
14
|
|
|
15
15
|
DEFAULT_PORT: int = 9877
|
|
16
16
|
|
|
17
|
+
# 长操作(wait_game_time / combo)的客户端 wall-time 生死线。
|
|
18
|
+
#
|
|
19
|
+
# 这类操作的「完成」是 game-time 维度(Godot 帧数推进),与 wall time 关系不可
|
|
20
|
+
# 预测:Movie Maker (--write-movie) 模式下 wall ≈ 4-5× game,且随分辨率/fps/盘速
|
|
21
|
+
# 漂移。任何 seconds-scaled 公式都会在某个组合下假超时(issue #45)。
|
|
22
|
+
#
|
|
23
|
+
# 改用固定大值后:正常完成时 server 自然回包;server 真死循环时由 600s 兜底
|
|
24
|
+
# (而不是 55s 把还活着的录像炸掉);死连接由 websockets 库的 ping/pong 心跳
|
|
25
|
+
# 在 ~40s 内独立检测,与本上限无关。
|
|
26
|
+
LONG_OP_CLIENT_TIMEOUT: float = 600.0
|
|
27
|
+
|
|
17
28
|
|
|
18
29
|
class RpcError(RuntimeError):
|
|
19
30
|
"""Raised when GameBridge returns a JSON-RPC error response.
|
|
@@ -100,6 +111,11 @@ class GameClient:
|
|
|
100
111
|
f"ws://127.0.0.1:{self._port}",
|
|
101
112
|
proxy=None,
|
|
102
113
|
open_timeout=open_timeout,
|
|
114
|
+
# 显式锁住 ws 心跳——issue #45 治本依赖:long ops 去掉
|
|
115
|
+
# seconds-scaled wall 上限后,死连接靠这层独立检测。
|
|
116
|
+
# 与 websockets 当前默认对齐,防库升级悄悄改默认。
|
|
117
|
+
ping_interval=20, # 每 20s 无流量发一次 ping
|
|
118
|
+
ping_timeout=20, # pong 20s 未回 → 关连接(≈40s 内可发现死链)
|
|
103
119
|
)
|
|
104
120
|
self._listen_task = asyncio.create_task(self._listen())
|
|
105
121
|
logger.info("Connected to GameBridge on port %d", self._port)
|
|
@@ -263,8 +279,9 @@ class GameClient:
|
|
|
263
279
|
async def wait_game_time(self, seconds: float) -> dict:
|
|
264
280
|
"""按 Godot game time 等待 N 秒。
|
|
265
281
|
|
|
266
|
-
|
|
267
|
-
|
|
282
|
+
客户端用固定 ``LONG_OP_CLIENT_TIMEOUT`` 生死线(issue #45):game-time
|
|
283
|
+
与 wall-time 比值受录像模式 / 分辨率 / 盘速 / fps 影响不可预测,任何
|
|
284
|
+
seconds-scaled 公式都会在某个组合下假超时。死连接由 ws ping/pong 兜底。
|
|
268
285
|
seconds <= 0 时客户端短路返回,不发 RPC。
|
|
269
286
|
"""
|
|
270
287
|
if seconds <= 0:
|
|
@@ -272,7 +289,7 @@ class GameClient:
|
|
|
272
289
|
return await self.request(
|
|
273
290
|
"wait_game_time",
|
|
274
291
|
{"seconds": seconds},
|
|
275
|
-
timeout=
|
|
292
|
+
timeout=LONG_OP_CLIENT_TIMEOUT,
|
|
276
293
|
)
|
|
277
294
|
|
|
278
295
|
# ---- Input simulation API ----
|
|
@@ -294,14 +311,10 @@ class GameClient:
|
|
|
294
311
|
)
|
|
295
312
|
|
|
296
313
|
async def combo(self, steps: list[dict]) -> dict:
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
)
|
|
300
|
-
# movie maker (--write-movie) 模式下 Godot 渲染比实时慢(大分辨率 +
|
|
301
|
-
# MJPEG 编码开销),客户端墙钟等待需要 3× 安全系数 + 10s base,
|
|
302
|
-
# 避免 combo 还没在游戏时间跑完就被 TimeoutError 打断。
|
|
314
|
+
# 客户端用固定 LONG_OP_CLIENT_TIMEOUT 生死线(issue #45):见
|
|
315
|
+
# wait_game_time 注释——同样的 game-time / wall-time 不对齐问题。
|
|
303
316
|
return await self.request(
|
|
304
|
-
"input_combo", {"steps": steps}, timeout=
|
|
317
|
+
"input_combo", {"steps": steps}, timeout=LONG_OP_CLIENT_TIMEOUT
|
|
305
318
|
)
|
|
306
319
|
|
|
307
320
|
async def combo_cancel(self) -> dict:
|
|
@@ -35,6 +35,8 @@ godot-cli-control daemon stop
|
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
> As of this version, `daemon start` autodetects headless mode by checking `stdout.isatty()`. Pipes, CI, and agent shell-outs run headless by default; an interactive terminal still gets a window. The explicit flags below are only needed to override: `--headless` forces headless even in a TTY; `--gui` forces a window even when stdout is piped.
|
|
38
|
+
>
|
|
39
|
+
> **`run <script>` adds one more layer**: it grep's the script source for `screenshot`. If found, headless is force-flipped to GUI even on non-TTY shells — headless dummy renderer can't read viewport texture, so `bridge.screenshot(...)` would otherwise hard-fail with code `1006`. Pass `--no-gui-auto` to disable this detection; explicit `--headless` / `--gui` still win.
|
|
38
40
|
|
|
39
41
|
## Exit codes
|
|
40
42
|
|
|
@@ -118,7 +120,7 @@ Three numeric ranges cohabit in `error.code`. Knowing which is which lets you de
|
|
|
118
120
|
|---|---|
|
|
119
121
|
| `-32600` | Malformed request (missing / non-string `method`). Bug in client; should never reach an agent. |
|
|
120
122
|
| `-32601` | Unknown method name. Bug; means client + plugin versions drifted. |
|
|
121
|
-
| `-32602` | Invalid params: missing required field, blocked property/method (security blacklist), out-of-range value, value-type mismatch on `set` (e.g. `Vector2` property given an array of wrong length / non-numeric elements),
|
|
123
|
+
| `-32602` | Invalid params: missing required field, blocked property/method (security blacklist), out-of-range value, value-type mismatch on `set` (e.g. `Vector2` property given an array of wrong length / non-numeric elements), node-isn't-clickable (e.g. you `click`'d a `Node2D`), or `hold` given `duration ≤ 0` (use `press` for an indefinite hold). Don't retry; the request shape is wrong. |
|
|
122
124
|
|
|
123
125
|
**Client-side (CLI / GameClient) — `-1xxx`:**
|
|
124
126
|
|
|
@@ -153,7 +155,7 @@ Server vs client ranges never overlap, so a single `code` field is unambiguous.
|
|
|
153
155
|
**Input simulation:**
|
|
154
156
|
- `press <action>` / `release <action>` — sticky press
|
|
155
157
|
- `tap <action> [duration]` — press → wait → release
|
|
156
|
-
- `hold <action> <duration>` — auto-release after N seconds
|
|
158
|
+
- `hold <action> <duration>` — auto-release after N seconds (`duration` must be `> 0`; for an indefinite hold use `press`)
|
|
157
159
|
- `combo --steps-json '[...]'` (or `combo file.json` / `combo -` for stdin) — sequence
|
|
158
160
|
- `combo-cancel` — abort running combo
|
|
159
161
|
- `release-all` — release everything
|
|
@@ -387,9 +389,11 @@ pytest_plugins = ["godot_cli_control.pytest_plugin"]
|
|
|
387
389
|
- **Daemon won't start** — check `.cli_control/godot_bin` exists and points at a real Godot 4 binary, or `export GODOT_BIN=/path/to/godot`. See `godot-cli-control init -h` for the full lookup chain.
|
|
388
390
|
- **Output flags work in any position** — `--json` / `--text` / `--no-json` are accepted both before and after subcommands as of this fix. `--port N` is still top-level only; pass it before the subcommand.
|
|
389
391
|
- **`combo` rejects everything with `1004`** — a combo is already running. Call `combo-cancel` (or `release-all`) to abort.
|
|
392
|
+
- **`hold` / `press` persist after the command returns** — by design. Each CLI command is its own short-lived connection that closes *cleanly*, and a clean close does **not** release inputs. `hold <action> <dur>` auto-releases after `<dur>` seconds (its timer keeps running in the daemon); a sticky `press <action>` stays held until you call `release <action>` / `release-all` (or the daemon's idle-timeout shuts it down). If a character looks stuck moving, you probably left a `press` dangling — run `release-all`. (An *abnormal* drop — your client crashing or being killed mid-session — does trigger a safety `release-all`, so stuck keys can't outlive a dead client.)
|
|
390
393
|
- **`tree` returns `1005 "scene tree too large"`** — your scene has more than 5000 visible nodes (a Grid / spawned-bullets situation). Pass `--max-nodes 200` to cap, or `children <path>` for one specific subtree.
|
|
391
394
|
- **`set` with a string that *looks* like JSON** — value parser parses JSON first. To force a literal `"42"` string, pass `'"42"'`; to set a literal hash sign or array text, JSON-encode it.
|
|
392
395
|
- **`daemon start` opens a window when I expected headless** — your stdout is a TTY (interactive terminal). Pass `--headless` explicitly, or shell out from a context where stdout is piped.
|
|
396
|
+
- **`run <script>` opens a window even though stdout is piped** — by design. `run` grep's the script for `screenshot` and force-flips to GUI when found, so `bridge.screenshot(...)` doesn't 1006-fail under the dummy renderer. Pass `--no-gui-auto` to disable detection; explicit `--headless` always wins. See issue #65.
|
|
393
397
|
- **`screenshot` used to fail with `1006` on the first call** — fixed. GameBridge now waits for the viewport's first frame before opening the port, so `connect succeeded` implies `viewport has rendered ≥ once`. The magic `bridge.wait(1.5)` before the first screenshot in older example scripts is no longer needed.
|
|
394
398
|
|
|
395
399
|
---
|
|
@@ -1140,6 +1140,47 @@ def test_combo_preflight_rejects_no_steps_before_connecting(
|
|
|
1140
1140
|
assert "必须提供" in payload["error"]["message"]
|
|
1141
1141
|
|
|
1142
1142
|
|
|
1143
|
+
@pytest.mark.parametrize("bad_duration", ["0", "-1.5", "abc"])
|
|
1144
|
+
def test_hold_preflight_rejects_bad_duration_before_connecting(
|
|
1145
|
+
bad_duration: str,
|
|
1146
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1147
|
+
capsys: pytest.CaptureFixture[str],
|
|
1148
|
+
) -> None:
|
|
1149
|
+
"""``hold`` 的 duration <= 0 或非数字必须在连 daemon **之前**报 EXIT_USAGE。
|
|
1150
|
+
|
|
1151
|
+
duration<=0 会让动作下一帧就释放(只生效一帧);无限按住该用 ``press``。
|
|
1152
|
+
见 issue #71。"""
|
|
1153
|
+
import json as _json
|
|
1154
|
+
|
|
1155
|
+
from godot_cli_control.cli import EXIT_USAGE, main
|
|
1156
|
+
|
|
1157
|
+
class _ShouldNotConnect:
|
|
1158
|
+
def __init__(self, *_: Any, **__: Any) -> None:
|
|
1159
|
+
raise AssertionError("preflight 失效:hold duration 非法时不应连 daemon")
|
|
1160
|
+
|
|
1161
|
+
import godot_cli_control.cli as cli_mod
|
|
1162
|
+
|
|
1163
|
+
monkeypatch.setattr(cli_mod, "GameClient", _ShouldNotConnect)
|
|
1164
|
+
monkeypatch.setattr(sys, "argv", ["godot-cli-control", "hold", "jump", bad_duration])
|
|
1165
|
+
|
|
1166
|
+
with pytest.raises(SystemExit) as exc:
|
|
1167
|
+
main()
|
|
1168
|
+
assert exc.value.code == EXIT_USAGE
|
|
1169
|
+
payload = _json.loads(capsys.readouterr().out.strip())
|
|
1170
|
+
assert payload["ok"] is False
|
|
1171
|
+
assert "duration" in payload["error"]["message"]
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def test_hold_preflight_accepts_positive_duration() -> None:
|
|
1175
|
+
"""合法 duration > 0 不应被 preflight 拦下(让它进到连接环节)。"""
|
|
1176
|
+
import argparse
|
|
1177
|
+
|
|
1178
|
+
from godot_cli_control.cli import _preflight_hold
|
|
1179
|
+
|
|
1180
|
+
ns = argparse.Namespace(duration="1.5")
|
|
1181
|
+
_preflight_hold(ns) # 不抛即通过
|
|
1182
|
+
|
|
1183
|
+
|
|
1143
1184
|
def test_combo_preflight_caches_steps_to_avoid_stdin_double_read(
|
|
1144
1185
|
monkeypatch: pytest.MonkeyPatch,
|
|
1145
1186
|
) -> None:
|
|
@@ -1733,6 +1774,191 @@ class TestDaemonHeadlessAutodetect:
|
|
|
1733
1774
|
with pytest.raises(SystemExit):
|
|
1734
1775
|
parser.parse_args(["daemon", "start", "--headless", "--gui"])
|
|
1735
1776
|
|
|
1777
|
+
def test_force_gui_hint_flips_pipe_to_gui(
|
|
1778
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
1779
|
+
) -> None:
|
|
1780
|
+
"""脚本含 screenshot 时 cmd_run 传 force_gui_hint=True,
|
|
1781
|
+
非 TTY 默认从 headless 翻成 GUI(issue #65)。"""
|
|
1782
|
+
from godot_cli_control.cli import _resolve_headless
|
|
1783
|
+
|
|
1784
|
+
monkeypatch.setattr("sys.stdout.isatty", lambda: False)
|
|
1785
|
+
ns = type("NS", (), {"headless": False, "gui": False})()
|
|
1786
|
+
assert _resolve_headless(ns, force_gui_hint=True) is False
|
|
1787
|
+
|
|
1788
|
+
def test_explicit_headless_still_wins_over_force_gui_hint(
|
|
1789
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
1790
|
+
) -> None:
|
|
1791
|
+
"""用户显式 --headless 永远赢 —— 即使脚本含 screenshot,
|
|
1792
|
+
用户也可能就是想跑 headless(CI 不需要截图的子集等)。"""
|
|
1793
|
+
from godot_cli_control.cli import _resolve_headless
|
|
1794
|
+
|
|
1795
|
+
monkeypatch.setattr("sys.stdout.isatty", lambda: False)
|
|
1796
|
+
ns = type("NS", (), {"headless": True, "gui": False})()
|
|
1797
|
+
assert _resolve_headless(ns, force_gui_hint=True) is True
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
class TestScriptLikelyUsesScreenshot:
|
|
1801
|
+
def test_detects_method_call(self, tmp_path: Path) -> None:
|
|
1802
|
+
from godot_cli_control.cli import _script_likely_uses_screenshot
|
|
1803
|
+
|
|
1804
|
+
script = tmp_path / "s.py"
|
|
1805
|
+
script.write_text(
|
|
1806
|
+
"def run(bridge):\n bridge.screenshot('/tmp/x.png')\n",
|
|
1807
|
+
encoding="utf-8",
|
|
1808
|
+
)
|
|
1809
|
+
assert _script_likely_uses_screenshot(script) is True
|
|
1810
|
+
|
|
1811
|
+
def test_no_match_when_script_clean(self, tmp_path: Path) -> None:
|
|
1812
|
+
from godot_cli_control.cli import _script_likely_uses_screenshot
|
|
1813
|
+
|
|
1814
|
+
script = tmp_path / "s.py"
|
|
1815
|
+
script.write_text("def run(bridge):\n bridge.click('/root/A')\n", "utf-8")
|
|
1816
|
+
assert _script_likely_uses_screenshot(script) is False
|
|
1817
|
+
|
|
1818
|
+
def test_missing_file_returns_false_not_raises(self, tmp_path: Path) -> None:
|
|
1819
|
+
"""读不到不抛 —— 让 cmd_run 走原 isatty 默认,"脚本不存在"
|
|
1820
|
+
由后续 script_path.exists() 检查统一报错。"""
|
|
1821
|
+
from godot_cli_control.cli import _script_likely_uses_screenshot
|
|
1822
|
+
|
|
1823
|
+
assert _script_likely_uses_screenshot(tmp_path / "missing.py") is False
|
|
1824
|
+
|
|
1825
|
+
|
|
1826
|
+
class TestCmdRunGuiAutoDetect:
|
|
1827
|
+
"""issue #65:cli run 静态检测脚本含 screenshot 时,非 TTY 也强制开窗。"""
|
|
1828
|
+
|
|
1829
|
+
@staticmethod
|
|
1830
|
+
def _mock_run_pipeline(
|
|
1831
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1832
|
+
) -> dict[str, Any]:
|
|
1833
|
+
"""拦截 daemon.start / is_running / current_port / stop +
|
|
1834
|
+
_exec_user_script,让 cmd_run 跑完整路径但不真起进程。
|
|
1835
|
+
返回 dict 记录 daemon.start kwargs,测试断言用。"""
|
|
1836
|
+
import godot_cli_control.cli as cli_mod
|
|
1837
|
+
import godot_cli_control.daemon as daemon_mod
|
|
1838
|
+
|
|
1839
|
+
captured: dict[str, Any] = {}
|
|
1840
|
+
|
|
1841
|
+
def _is_running(self: Any) -> bool:
|
|
1842
|
+
return False
|
|
1843
|
+
|
|
1844
|
+
def _start(self: Any, **kw: Any) -> None:
|
|
1845
|
+
captured["start_kwargs"] = kw
|
|
1846
|
+
|
|
1847
|
+
def _current_port(self: Any) -> int:
|
|
1848
|
+
return 12345
|
|
1849
|
+
|
|
1850
|
+
def _stop(self: Any) -> int:
|
|
1851
|
+
return 0
|
|
1852
|
+
|
|
1853
|
+
monkeypatch.setattr(daemon_mod.Daemon, "is_running", _is_running)
|
|
1854
|
+
monkeypatch.setattr(daemon_mod.Daemon, "start", _start)
|
|
1855
|
+
monkeypatch.setattr(daemon_mod.Daemon, "current_port", _current_port)
|
|
1856
|
+
monkeypatch.setattr(daemon_mod.Daemon, "stop", _stop)
|
|
1857
|
+
monkeypatch.setattr(
|
|
1858
|
+
cli_mod,
|
|
1859
|
+
"_exec_user_script",
|
|
1860
|
+
lambda *a, **kw: 0,
|
|
1861
|
+
)
|
|
1862
|
+
# 非 TTY ── 模拟 subagent / pipe / CI
|
|
1863
|
+
monkeypatch.setattr("sys.stdout.isatty", lambda: False)
|
|
1864
|
+
return captured
|
|
1865
|
+
|
|
1866
|
+
def test_script_with_screenshot_forces_gui_under_pipe(
|
|
1867
|
+
self,
|
|
1868
|
+
tmp_path: Path,
|
|
1869
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1870
|
+
) -> None:
|
|
1871
|
+
import argparse
|
|
1872
|
+
|
|
1873
|
+
from godot_cli_control.cli import OUTPUT_JSON, cmd_run
|
|
1874
|
+
|
|
1875
|
+
captured = self._mock_run_pipeline(monkeypatch)
|
|
1876
|
+
script = tmp_path / "s.py"
|
|
1877
|
+
script.write_text(
|
|
1878
|
+
"def run(bridge):\n bridge.screenshot('/tmp/x.png')\n",
|
|
1879
|
+
encoding="utf-8",
|
|
1880
|
+
)
|
|
1881
|
+
ns = argparse.Namespace(
|
|
1882
|
+
script=str(script),
|
|
1883
|
+
record=False,
|
|
1884
|
+
movie_path=None,
|
|
1885
|
+
headless=False,
|
|
1886
|
+
gui=False,
|
|
1887
|
+
no_gui_auto=False,
|
|
1888
|
+
fps=30,
|
|
1889
|
+
port=0,
|
|
1890
|
+
idle_timeout="0",
|
|
1891
|
+
output_format=OUTPUT_JSON,
|
|
1892
|
+
)
|
|
1893
|
+
rc = cmd_run(ns)
|
|
1894
|
+
assert rc == 0
|
|
1895
|
+
# 非 TTY 默认 headless=True,但脚本含 screenshot → 翻转到 False
|
|
1896
|
+
assert captured["start_kwargs"]["headless"] is False
|
|
1897
|
+
|
|
1898
|
+
def test_no_gui_auto_disables_detection(
|
|
1899
|
+
self,
|
|
1900
|
+
tmp_path: Path,
|
|
1901
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1902
|
+
) -> None:
|
|
1903
|
+
import argparse
|
|
1904
|
+
|
|
1905
|
+
from godot_cli_control.cli import OUTPUT_JSON, cmd_run
|
|
1906
|
+
|
|
1907
|
+
captured = self._mock_run_pipeline(monkeypatch)
|
|
1908
|
+
script = tmp_path / "s.py"
|
|
1909
|
+
script.write_text(
|
|
1910
|
+
"def run(bridge):\n bridge.screenshot('/tmp/x.png')\n",
|
|
1911
|
+
encoding="utf-8",
|
|
1912
|
+
)
|
|
1913
|
+
ns = argparse.Namespace(
|
|
1914
|
+
script=str(script),
|
|
1915
|
+
record=False,
|
|
1916
|
+
movie_path=None,
|
|
1917
|
+
headless=False,
|
|
1918
|
+
gui=False,
|
|
1919
|
+
no_gui_auto=True, # ← opt-out
|
|
1920
|
+
fps=30,
|
|
1921
|
+
port=0,
|
|
1922
|
+
idle_timeout="0",
|
|
1923
|
+
output_format=OUTPUT_JSON,
|
|
1924
|
+
)
|
|
1925
|
+
rc = cmd_run(ns)
|
|
1926
|
+
assert rc == 0
|
|
1927
|
+
# opt-out → 回到 isatty 默认(非 TTY = headless)
|
|
1928
|
+
assert captured["start_kwargs"]["headless"] is True
|
|
1929
|
+
|
|
1930
|
+
def test_script_without_screenshot_keeps_headless_default(
|
|
1931
|
+
self,
|
|
1932
|
+
tmp_path: Path,
|
|
1933
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1934
|
+
) -> None:
|
|
1935
|
+
import argparse
|
|
1936
|
+
|
|
1937
|
+
from godot_cli_control.cli import OUTPUT_JSON, cmd_run
|
|
1938
|
+
|
|
1939
|
+
captured = self._mock_run_pipeline(monkeypatch)
|
|
1940
|
+
script = tmp_path / "s.py"
|
|
1941
|
+
script.write_text(
|
|
1942
|
+
"def run(bridge):\n bridge.click('/root/A')\n",
|
|
1943
|
+
encoding="utf-8",
|
|
1944
|
+
)
|
|
1945
|
+
ns = argparse.Namespace(
|
|
1946
|
+
script=str(script),
|
|
1947
|
+
record=False,
|
|
1948
|
+
movie_path=None,
|
|
1949
|
+
headless=False,
|
|
1950
|
+
gui=False,
|
|
1951
|
+
no_gui_auto=False,
|
|
1952
|
+
fps=30,
|
|
1953
|
+
port=0,
|
|
1954
|
+
idle_timeout="0",
|
|
1955
|
+
output_format=OUTPUT_JSON,
|
|
1956
|
+
)
|
|
1957
|
+
rc = cmd_run(ns)
|
|
1958
|
+
assert rc == 0
|
|
1959
|
+
# 脚本无 screenshot → 不触发翻转,按 isatty=False 走 headless
|
|
1960
|
+
assert captured["start_kwargs"]["headless"] is True
|
|
1961
|
+
|
|
1736
1962
|
|
|
1737
1963
|
def test_run_rpc_tree_truncated_envelope(
|
|
1738
1964
|
capsys: pytest.CaptureFixture[str],
|
|
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, patch
|
|
|
9
9
|
import pytest
|
|
10
10
|
import websockets
|
|
11
11
|
|
|
12
|
-
from godot_cli_control.client import GameClient
|
|
12
|
+
from godot_cli_control.client import LONG_OP_CLIENT_TIMEOUT, GameClient
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
# ---- Test 1: proxy=None 显式传给 websockets.connect ----
|
|
@@ -465,3 +465,85 @@ async def test_get_scene_tree_omits_max_nodes_when_none() -> None:
|
|
|
465
465
|
client_mod.GameClient.request = monkeypatch_target
|
|
466
466
|
assert captured["params"] == {"depth": 2}
|
|
467
467
|
assert "max_nodes" not in captured["params"]
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ---- issue #45: wait_game_time / combo 不能给 game-time 操作设 wall-time 上限 ----
|
|
471
|
+
#
|
|
472
|
+
# 旧公式 seconds*3+10 假设 wall ≤ 3× game,在 Movie Maker (--write-movie) 模式下
|
|
473
|
+
# 实测 wall ≈ 4-5× game,必假超时(bridge.wait(15) → 55s timeout < 60s+ wall)。
|
|
474
|
+
# 治本:去掉 seconds-scaled wall 上限,固定一个生死线,死连接靠 ws ping/pong。
|
|
475
|
+
#
|
|
476
|
+
# 测试用「两次差距大的 seconds 拿到同一 timeout」直接证明 decoupling,
|
|
477
|
+
# 比单点等值断言更稳——后者跟「巧合 == 常量」的假阳性形态无法区分。
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@pytest.mark.asyncio
|
|
481
|
+
async def test_wait_game_time_client_timeout_decoupled_from_seconds() -> None:
|
|
482
|
+
"""issue #45: wait_game_time 客户端 timeout 必须与 seconds 解耦。"""
|
|
483
|
+
import godot_cli_control.client as client_mod
|
|
484
|
+
|
|
485
|
+
captured: list = []
|
|
486
|
+
|
|
487
|
+
async def fake_request(self, method, params=None, timeout=30.0):
|
|
488
|
+
captured.append(timeout)
|
|
489
|
+
return {"success": True}
|
|
490
|
+
|
|
491
|
+
client = client_mod.GameClient(port=1)
|
|
492
|
+
monkeypatch_target = client_mod.GameClient.request
|
|
493
|
+
client_mod.GameClient.request = fake_request # type: ignore
|
|
494
|
+
try:
|
|
495
|
+
await client.wait_game_time(1.0)
|
|
496
|
+
await client.wait_game_time(120.0)
|
|
497
|
+
finally:
|
|
498
|
+
client_mod.GameClient.request = monkeypatch_target
|
|
499
|
+
assert captured == [LONG_OP_CLIENT_TIMEOUT, LONG_OP_CLIENT_TIMEOUT], (
|
|
500
|
+
f"wait_game_time 应固定使用 {LONG_OP_CLIENT_TIMEOUT}s 生死线、与 seconds 无关,"
|
|
501
|
+
f"实际 {captured}"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@pytest.mark.asyncio
|
|
506
|
+
async def test_combo_client_timeout_decoupled_from_steps_total() -> None:
|
|
507
|
+
"""issue #45: combo() 与 wait_game_time 同源问题,client timeout 同样与 total 解耦。"""
|
|
508
|
+
import godot_cli_control.client as client_mod
|
|
509
|
+
|
|
510
|
+
captured: list = []
|
|
511
|
+
|
|
512
|
+
async def fake_request(self, method, params=None, timeout=30.0):
|
|
513
|
+
captured.append(timeout)
|
|
514
|
+
return {"success": True}
|
|
515
|
+
|
|
516
|
+
client = client_mod.GameClient(port=1)
|
|
517
|
+
monkeypatch_target = client_mod.GameClient.request
|
|
518
|
+
client_mod.GameClient.request = fake_request # type: ignore
|
|
519
|
+
try:
|
|
520
|
+
await client.combo([{"action": "x", "duration": 0.5}])
|
|
521
|
+
await client.combo([{"action": "x", "duration": 60.0}])
|
|
522
|
+
finally:
|
|
523
|
+
client_mod.GameClient.request = monkeypatch_target
|
|
524
|
+
assert captured == [LONG_OP_CLIENT_TIMEOUT, LONG_OP_CLIENT_TIMEOUT]
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@pytest.mark.asyncio
|
|
528
|
+
async def test_connect_locks_ws_ping_keepalive() -> None:
|
|
529
|
+
"""issue #45 治本依赖:去掉 wall 上限后,死连接靠 ws ping/pong 检测。
|
|
530
|
+
|
|
531
|
+
显式锁住 ping_interval / ping_timeout(与 websockets 默认对齐),
|
|
532
|
+
防库升级把默认改了导致死连接卡 600s。
|
|
533
|
+
"""
|
|
534
|
+
fake_ws = AsyncMock()
|
|
535
|
+
with patch(
|
|
536
|
+
"godot_cli_control.client.websockets.connect",
|
|
537
|
+
new=AsyncMock(return_value=fake_ws),
|
|
538
|
+
) as mock_connect:
|
|
539
|
+
client = GameClient(port=9999)
|
|
540
|
+
try:
|
|
541
|
+
await client.connect(retries=1)
|
|
542
|
+
finally:
|
|
543
|
+
if client._listen_task:
|
|
544
|
+
client._listen_task.cancel()
|
|
545
|
+
_, kwargs = mock_connect.call_args
|
|
546
|
+
assert kwargs.get("ping_interval") == 20, \
|
|
547
|
+
"GameClient.connect() must lock ping_interval (ws keepalive)"
|
|
548
|
+
assert kwargs.get("ping_timeout") == 20, \
|
|
549
|
+
"GameClient.connect() must lock ping_timeout (ws keepalive)"
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""端到端回归:真实 Godot daemon 下的输入持续性(issue #70)。
|
|
2
|
+
|
|
3
|
+
拦的回归:CLI 每条子命令都是独立连接、跑完即「干净关闭」。曾经 GameBridge
|
|
4
|
+
在断连时无条件 release_all,导致 ``hold`` 的定时器没倒计时就被清掉(只生效
|
|
5
|
+
一帧)、sticky ``press`` 也无法跨命令存活。这个 bug 当时 GUT(mock socket)
|
|
6
|
+
和 pytest(mock subprocess)都没逮到 —— 只有真实 daemon 端到端能复现。
|
|
7
|
+
|
|
8
|
+
覆盖三条链路:
|
|
9
|
+
1. 干净关闭后 ``hold`` 仍持续,duration 到点由定时器自动释放(不是断连释放)。
|
|
10
|
+
2. sticky ``press`` 跨命令存活,直到 ``release-all``。
|
|
11
|
+
3. 异常掉线(无 WebSocket close frame,close_code == -1)触发 release_all 兜底。
|
|
12
|
+
|
|
13
|
+
需要真实 Godot 4:设置 ``GODOT_BIN`` 指向可执行文件,否则整文件 skip。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import pytest
|
|
28
|
+
|
|
29
|
+
_GODOT_BIN = os.environ.get("GODOT_BIN")
|
|
30
|
+
_ADDON_SRC = Path(__file__).resolve().parents[2] / "addons" / "godot_cli_control"
|
|
31
|
+
|
|
32
|
+
pytestmark = pytest.mark.skipif(
|
|
33
|
+
not _GODOT_BIN or not Path(_GODOT_BIN).exists(),
|
|
34
|
+
reason="需要真实 Godot 4:设置 GODOT_BIN 指向可执行文件",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# 用 `python -m godot_cli_control` 调 CLI:与 PATH 无关,always 命中当前环境。
|
|
38
|
+
_CLI = [sys.executable, "-m", "godot_cli_control"]
|
|
39
|
+
|
|
40
|
+
_PROJECT_GODOT = """\
|
|
41
|
+
config_version=5
|
|
42
|
+
|
|
43
|
+
[application]
|
|
44
|
+
config/name="gcc_e2e"
|
|
45
|
+
run/main_scene="res://main.tscn"
|
|
46
|
+
|
|
47
|
+
[autoload]
|
|
48
|
+
GameBridgeNode="*res://addons/godot_cli_control/bridge/game_bridge.gd"
|
|
49
|
+
|
|
50
|
+
[debug]
|
|
51
|
+
settings/stdout/print_fps=false
|
|
52
|
+
|
|
53
|
+
[editor_plugins]
|
|
54
|
+
enabled=PackedStringArray("res://addons/godot_cli_control/plugin.cfg")
|
|
55
|
+
|
|
56
|
+
[input]
|
|
57
|
+
move_right={"deadzone":0.5,"events":[]}
|
|
58
|
+
jump={"deadzone":0.5,"events":[]}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
_MAIN_TSCN = """\
|
|
62
|
+
[gd_scene format=3]
|
|
63
|
+
|
|
64
|
+
[node name="Main" type="Node"]
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _run_cli(project: Path, *args: str, timeout: float = 60.0) -> dict[str, Any]:
|
|
69
|
+
"""跑一条 CLI 子命令(独立进程 = 独立连接),解析最后一行 JSON 信封。"""
|
|
70
|
+
proc = subprocess.run(
|
|
71
|
+
_CLI + list(args),
|
|
72
|
+
cwd=project,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
timeout=timeout,
|
|
76
|
+
)
|
|
77
|
+
# 信封是 stdout 单行 JSON;daemon start 等可能先打 stderr 进度,取最后的 JSON 行。
|
|
78
|
+
json_lines = [ln for ln in proc.stdout.splitlines() if ln.strip().startswith("{")]
|
|
79
|
+
assert json_lines, f"无 JSON 输出:args={args} stdout={proc.stdout!r} stderr={proc.stderr!r}"
|
|
80
|
+
return json.loads(json_lines[-1])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _pressed(project: Path) -> list[str]:
|
|
84
|
+
payload = _run_cli(project, "pressed")
|
|
85
|
+
assert payload["ok"] is True, payload
|
|
86
|
+
return payload["result"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.fixture(scope="module")
|
|
90
|
+
def godot_project(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
91
|
+
"""搭一个最小真实 Godot 工程(addon + autoload + 两个 InputMap 动作)并导入一次。"""
|
|
92
|
+
proj = tmp_path_factory.mktemp("gcc_e2e")
|
|
93
|
+
(proj / "addons").mkdir()
|
|
94
|
+
shutil.copytree(_ADDON_SRC, proj / "addons" / "godot_cli_control")
|
|
95
|
+
(proj / "project.godot").write_text(_PROJECT_GODOT)
|
|
96
|
+
(proj / "main.tscn").write_text(_MAIN_TSCN)
|
|
97
|
+
|
|
98
|
+
# 先跑一次编辑器导入,注册全局 class(CliControlErrorCodes)+ 资源 .import。
|
|
99
|
+
imp = subprocess.run(
|
|
100
|
+
[_GODOT_BIN, "--headless", "--editor", "--quit", "--path", str(proj)],
|
|
101
|
+
capture_output=True,
|
|
102
|
+
text=True,
|
|
103
|
+
timeout=180,
|
|
104
|
+
)
|
|
105
|
+
assert imp.returncode == 0, f"Godot 导入失败:{imp.stdout}\n{imp.stderr}"
|
|
106
|
+
return proj
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.fixture
|
|
110
|
+
def daemon(godot_project: Path) -> Any:
|
|
111
|
+
"""每个用例起/停一个真实 headless daemon;teardown 兜底 release-all + stop。"""
|
|
112
|
+
start = _run_cli(godot_project, "daemon", "start", "--headless", timeout=90)
|
|
113
|
+
assert start["ok"] is True and start["result"]["started"], start
|
|
114
|
+
port = start["result"]["port"]
|
|
115
|
+
try:
|
|
116
|
+
yield godot_project, port
|
|
117
|
+
finally:
|
|
118
|
+
_run_cli(godot_project, "release-all")
|
|
119
|
+
_run_cli(godot_project, "daemon", "stop", timeout=30)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_hold_persists_across_clean_disconnect_then_auto_releases(daemon: Any) -> None:
|
|
123
|
+
project, _ = daemon
|
|
124
|
+
# hold 1s:命令拿到响应后干净关闭连接 —— 断连不应清掉它。
|
|
125
|
+
held = _run_cli(project, "hold", "move_right", "1.0")
|
|
126
|
+
assert held["ok"] is True, held
|
|
127
|
+
|
|
128
|
+
# 紧跟一条独立命令:若断连清了,这里就空了(旧 bug:只生效一帧)。
|
|
129
|
+
assert "move_right" in _pressed(project), "干净关闭后 hold 应跨命令存活"
|
|
130
|
+
|
|
131
|
+
# 等过 duration:应由 advance_timers 定时器自动释放(不是断连释放)。
|
|
132
|
+
time.sleep(2.0)
|
|
133
|
+
assert _pressed(project) == [], "duration 到点后 hold 应被定时器自动释放"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_press_persists_until_release_all(daemon: Any) -> None:
|
|
137
|
+
project, _ = daemon
|
|
138
|
+
pressed = _run_cli(project, "press", "jump")
|
|
139
|
+
assert pressed["ok"] is True, pressed
|
|
140
|
+
|
|
141
|
+
# sticky press 没有定时器:跨多条独立命令仍应保持按下。
|
|
142
|
+
assert "jump" in _pressed(project), "sticky press 应跨命令存活"
|
|
143
|
+
time.sleep(1.0)
|
|
144
|
+
assert "jump" in _pressed(project), "press 不该自己释放"
|
|
145
|
+
|
|
146
|
+
rel = _run_cli(project, "release-all")
|
|
147
|
+
assert rel["ok"] is True, rel
|
|
148
|
+
assert _pressed(project) == [], "release-all 后应清空"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_abnormal_disconnect_releases_held_inputs(daemon: Any) -> None:
|
|
152
|
+
project, port = daemon
|
|
153
|
+
# 子进程:连上 → 发 hold → 硬退出(不发 close frame)→ daemon 侧 close_code = -1。
|
|
154
|
+
drop_script = (
|
|
155
|
+
"import asyncio, json, os, websockets\n"
|
|
156
|
+
"async def main():\n"
|
|
157
|
+
f" ws = await websockets.connect('ws://127.0.0.1:{port}')\n"
|
|
158
|
+
" await ws.send(json.dumps({'id':'1','method':'input_hold',"
|
|
159
|
+
"'params':{'action':'move_right','duration':30.0}}))\n"
|
|
160
|
+
" await ws.recv()\n"
|
|
161
|
+
" os._exit(0)\n"
|
|
162
|
+
"asyncio.run(main())\n"
|
|
163
|
+
)
|
|
164
|
+
drop = subprocess.run(
|
|
165
|
+
[sys.executable, "-c", drop_script], capture_output=True, text=True, timeout=30
|
|
166
|
+
)
|
|
167
|
+
assert drop.returncode == 0, f"abrupt-drop 客户端异常:{drop.stderr}"
|
|
168
|
+
|
|
169
|
+
# 异常掉线应触发 release_all 兜底(即便 hold 还剩 ~30s)。给 daemon 几帧检测断连。
|
|
170
|
+
deadline = time.time() + 5.0
|
|
171
|
+
while time.time() < deadline:
|
|
172
|
+
if _pressed(project) == []:
|
|
173
|
+
break
|
|
174
|
+
time.sleep(0.2)
|
|
175
|
+
assert _pressed(project) == [], "异常掉线应触发 release_all 清掉持有输入"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bin/run_cli_control.ps1
RENAMED
|
File without changes
|
{godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bin/run_cli_control.sh
RENAMED
|
File without changes
|
{godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/error_codes.gd
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/low_level_api.gd
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/run_gut.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/pytest_plugin.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/skills_install.py
RENAMED
|
File without changes
|
{godot_cli_control-0.2.7 → godot_cli_control-0.2.9}/python/godot_cli_control/templates/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|