godot-cli-control 0.2.8__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.
Files changed (55) hide show
  1. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/PKG-INFO +1 -1
  2. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/README.md +1 -1
  3. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/game_bridge.gd +20 -4
  4. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/input_simulation_api.gd +8 -0
  5. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +50 -0
  6. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +16 -0
  7. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/_version.py +2 -2
  8. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/cli.py +18 -1
  9. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/templates/skill/SKILL.md +3 -2
  10. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/test_cli.py +41 -0
  11. godot_cli_control-0.2.9/python/tests/test_e2e_input.py +175 -0
  12. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/.gitignore +0 -0
  13. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/LICENSE +0 -0
  14. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/CHANGELOG.md +0 -0
  15. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/LICENSE +0 -0
  16. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  17. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  18. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/error_codes.gd +0 -0
  19. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  20. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  21. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/low_level_api.gd +0 -0
  22. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  23. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/plugin.cfg +0 -0
  24. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/plugin.gd +0 -0
  25. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/plugin.gd.uid +0 -0
  26. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +0 -0
  27. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  28. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  29. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  30. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/pyproject.toml +0 -0
  31. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/README.md +0 -0
  32. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/__init__.py +0 -0
  33. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/__main__.py +0 -0
  34. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/_duration.py +0 -0
  35. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/bridge.py +0 -0
  36. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/client.py +0 -0
  37. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/daemon.py +0 -0
  38. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/init_cmd.py +0 -0
  39. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/pytest_plugin.py +0 -0
  40. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/registry.py +0 -0
  41. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/runner.py +0 -0
  42. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/skills_install.py +0 -0
  43. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/templates/__init__.py +0 -0
  44. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  45. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/__init__.py +0 -0
  46. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/test_bridge.py +0 -0
  47. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/test_cli_helpers.py +0 -0
  48. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/test_client.py +0 -0
  49. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/test_daemon.py +0 -0
  50. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/test_duration.py +0 -0
  51. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/test_init.py +0 -0
  52. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/test_pytest_plugin.py +0 -0
  53. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/test_registry.py +0 -0
  54. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/test_runner.py +0 -0
  55. {godot_cli_control-0.2.8 → godot_cli_control-0.2.9}/python/tests/test_skills_install.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godot-cli-control
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
5
  Author: kesar
6
6
  License: MIT
@@ -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, or `set` value-type mismatch — e.g. `Vector2` property given an array of wrong length / non-numeric elements) |
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`, …) |
@@ -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
- print("GameBridge: Client disconnected")
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.8'
22
- __version_tuple__ = version_tuple = (0, 2, 8)
21
+ __version__ = version = '0.2.9'
22
+ __version_tuple__ = version_tuple = (0, 2, 9)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -175,6 +175,22 @@ def _resolve_args_for_call(ns: argparse.Namespace) -> list:
175
175
  return [_parse_json_arg(a) for a in raw_args]
176
176
 
177
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
+
178
194
  def _preflight_combo(ns: argparse.Namespace) -> None:
179
195
  """连 daemon 前用同一份解析逻辑校验 combo 输入;抛 ValueError 即用法错。
180
196
 
@@ -549,9 +565,10 @@ RPC_SPECS: tuple[RpcSpec, ...] = (
549
565
  description="按住动作指定时长(秒),到点自动释放。",
550
566
  positionals=(
551
567
  Positional("action", None, "InputMap 动作名"),
552
- Positional("duration", None, "按住时长(秒)"),
568
+ Positional("duration", None, "按住时长(秒,必须 > 0)"),
553
569
  ),
554
570
  example="hold jump 1.5",
571
+ preflight=_preflight_hold,
555
572
  text_formatter=lambda r: f"holding: {r}",
556
573
  ),
557
574
  RpcSpec(
@@ -120,7 +120,7 @@ Three numeric ranges cohabit in `error.code`. Knowing which is which lets you de
120
120
  |---|---|
121
121
  | `-32600` | Malformed request (missing / non-string `method`). Bug in client; should never reach an agent. |
122
122
  | `-32601` | Unknown method name. Bug; means client + plugin versions drifted. |
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), or node-isn't-clickable (e.g. you `click`'d a `Node2D`). Don't retry; the request shape is wrong. |
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. |
124
124
 
125
125
  **Client-side (CLI / GameClient) — `-1xxx`:**
126
126
 
@@ -155,7 +155,7 @@ Server vs client ranges never overlap, so a single `code` field is unambiguous.
155
155
  **Input simulation:**
156
156
  - `press <action>` / `release <action>` — sticky press
157
157
  - `tap <action> [duration]` — press → wait → release
158
- - `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`)
159
159
  - `combo --steps-json '[...]'` (or `combo file.json` / `combo -` for stdin) — sequence
160
160
  - `combo-cancel` — abort running combo
161
161
  - `release-all` — release everything
@@ -389,6 +389,7 @@ pytest_plugins = ["godot_cli_control.pytest_plugin"]
389
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.
390
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.
391
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.)
392
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.
393
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.
394
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.
@@ -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:
@@ -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 清掉持有输入"