screenforge 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {screenforge-0.5.0/screenforge.egg-info → screenforge-0.6.0}/PKG-INFO +1 -1
  2. screenforge-0.6.0/cli/_version.py +1 -0
  3. {screenforge-0.5.0 → screenforge-0.6.0}/cli/playground_sink.py +54 -1
  4. {screenforge-0.5.0 → screenforge-0.6.0}/pyproject.toml +1 -1
  5. {screenforge-0.5.0 → screenforge-0.6.0/screenforge.egg-info}/PKG-INFO +1 -1
  6. {screenforge-0.5.0 → screenforge-0.6.0}/screenforge.egg-info/SOURCES.txt +1 -0
  7. screenforge-0.6.0/tests/test_dom_capture.py +136 -0
  8. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_playground_app.py +72 -0
  9. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_playground_sink.py +163 -0
  10. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_web_dom_complex_live.py +84 -0
  11. screenforge-0.5.0/cli/_version.py +0 -1
  12. {screenforge-0.5.0 → screenforge-0.6.0}/LICENSE +0 -0
  13. {screenforge-0.5.0 → screenforge-0.6.0}/README.md +0 -0
  14. {screenforge-0.5.0 → screenforge-0.6.0}/cli/__init__.py +0 -0
  15. {screenforge-0.5.0 → screenforge-0.6.0}/cli/dispatch.py +0 -0
  16. {screenforge-0.5.0 → screenforge-0.6.0}/cli/doctor.py +0 -0
  17. {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/__init__.py +0 -0
  18. {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/action.py +0 -0
  19. {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/default.py +0 -0
  20. {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/demo.py +0 -0
  21. {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/dry_run.py +0 -0
  22. {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/init.py +0 -0
  23. {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/plan.py +0 -0
  24. {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/workflow.py +0 -0
  25. {screenforge-0.5.0 → screenforge-0.6.0}/cli/parser.py +0 -0
  26. {screenforge-0.5.0 → screenforge-0.6.0}/cli/reporter.py +0 -0
  27. {screenforge-0.5.0 → screenforge-0.6.0}/cli/session.py +0 -0
  28. {screenforge-0.5.0 → screenforge-0.6.0}/cli/shared.py +0 -0
  29. {screenforge-0.5.0 → screenforge-0.6.0}/cli/shorthand.py +0 -0
  30. {screenforge-0.5.0 → screenforge-0.6.0}/cli/tool_protocol_handlers.py +0 -0
  31. {screenforge-0.5.0 → screenforge-0.6.0}/common/__init__.py +0 -0
  32. {screenforge-0.5.0 → screenforge-0.6.0}/common/adapters/__init__.py +0 -0
  33. {screenforge-0.5.0 → screenforge-0.6.0}/common/adapters/android_adapter.py +0 -0
  34. {screenforge-0.5.0 → screenforge-0.6.0}/common/adapters/base_adapter.py +0 -0
  35. {screenforge-0.5.0 → screenforge-0.6.0}/common/adapters/ios_adapter.py +0 -0
  36. {screenforge-0.5.0 → screenforge-0.6.0}/common/adapters/web_adapter.py +0 -0
  37. {screenforge-0.5.0 → screenforge-0.6.0}/common/ai.py +0 -0
  38. {screenforge-0.5.0 → screenforge-0.6.0}/common/ai_autonomous.py +0 -0
  39. {screenforge-0.5.0 → screenforge-0.6.0}/common/ai_heal.py +0 -0
  40. {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/__init__.py +0 -0
  41. {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/cache_hash.py +0 -0
  42. {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/cache_manager.py +0 -0
  43. {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/cache_stats.py +0 -0
  44. {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/cache_storage.py +0 -0
  45. {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/embedding_loader.py +0 -0
  46. {screenforge-0.5.0 → screenforge-0.6.0}/common/capabilities.py +0 -0
  47. {screenforge-0.5.0 → screenforge-0.6.0}/common/case_memory.py +0 -0
  48. {screenforge-0.5.0 → screenforge-0.6.0}/common/error_codes.py +0 -0
  49. {screenforge-0.5.0 → screenforge-0.6.0}/common/exceptions.py +0 -0
  50. {screenforge-0.5.0 → screenforge-0.6.0}/common/executor.py +0 -0
  51. {screenforge-0.5.0 → screenforge-0.6.0}/common/failure_diagnosis.py +0 -0
  52. {screenforge-0.5.0 → screenforge-0.6.0}/common/history_manager.py +0 -0
  53. {screenforge-0.5.0 → screenforge-0.6.0}/common/logs.py +0 -0
  54. {screenforge-0.5.0 → screenforge-0.6.0}/common/mcp_server.py +0 -0
  55. {screenforge-0.5.0 → screenforge-0.6.0}/common/preflight.py +0 -0
  56. {screenforge-0.5.0 → screenforge-0.6.0}/common/progress.py +0 -0
  57. {screenforge-0.5.0 → screenforge-0.6.0}/common/run_reporter.py +0 -0
  58. {screenforge-0.5.0 → screenforge-0.6.0}/common/run_resume.py +0 -0
  59. {screenforge-0.5.0 → screenforge-0.6.0}/common/runtime_modes.py +0 -0
  60. {screenforge-0.5.0 → screenforge-0.6.0}/common/tool_protocol.py +0 -0
  61. {screenforge-0.5.0 → screenforge-0.6.0}/common/visual_fallback.py +0 -0
  62. {screenforge-0.5.0 → screenforge-0.6.0}/common/workflow_schema.py +0 -0
  63. {screenforge-0.5.0 → screenforge-0.6.0}/config/__init__.py +0 -0
  64. {screenforge-0.5.0 → screenforge-0.6.0}/config/config.py +0 -0
  65. {screenforge-0.5.0 → screenforge-0.6.0}/config/env_loader.py +0 -0
  66. {screenforge-0.5.0 → screenforge-0.6.0}/screenforge.egg-info/dependency_links.txt +0 -0
  67. {screenforge-0.5.0 → screenforge-0.6.0}/screenforge.egg-info/entry_points.txt +0 -0
  68. {screenforge-0.5.0 → screenforge-0.6.0}/screenforge.egg-info/requires.txt +0 -0
  69. {screenforge-0.5.0 → screenforge-0.6.0}/screenforge.egg-info/top_level.txt +0 -0
  70. {screenforge-0.5.0 → screenforge-0.6.0}/setup.cfg +0 -0
  71. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_ai_autonomous.py +0 -0
  72. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_ai_brain.py +0 -0
  73. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_ai_heal.py +0 -0
  74. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_android_smoke_live.py +0 -0
  75. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_cache_manager.py +0 -0
  76. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_capabilities.py +0 -0
  77. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_cli_action_json.py +0 -0
  78. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_codegen_quality.py +0 -0
  79. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_dispatch.py +0 -0
  80. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_doctor_orphan_browser.py +0 -0
  81. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_error_codes.py +0 -0
  82. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_executor.py +0 -0
  83. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_failure_diagnosis.py +0 -0
  84. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_interaction_actions.py +0 -0
  85. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_ios_smoke_live.py +0 -0
  86. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_mcp_ref_cache.py +0 -0
  87. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_ml_optional.py +0 -0
  88. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_parser.py +0 -0
  89. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_playground_sink_integration.py +0 -0
  90. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_run_reporter.py +0 -0
  91. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_run_resume.py +0 -0
  92. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_runtime_modes.py +0 -0
  93. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_screenshot_annotator.py +0 -0
  94. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_shorthand.py +0 -0
  95. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_tool_protocol_diagnosis.py +0 -0
  96. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_utils_ios.py +0 -0
  97. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_utils_web.py +0 -0
  98. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_utils_xml.py +0 -0
  99. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_visual_fallback.py +0 -0
  100. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_web_adapter.py +0 -0
  101. {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_web_smoke_live.py +0 -0
  102. {screenforge-0.5.0 → screenforge-0.6.0}/utils/__init__.py +0 -0
  103. {screenforge-0.5.0 → screenforge-0.6.0}/utils/screenshot_annotator.py +0 -0
  104. {screenforge-0.5.0 → screenforge-0.6.0}/utils/utils_ios.py +0 -0
  105. {screenforge-0.5.0 → screenforge-0.6.0}/utils/utils_web.py +0 -0
  106. {screenforge-0.5.0 → screenforge-0.6.0}/utils/utils_xml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenforge
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: AI-driven cross-platform UI automation engine with test script generation
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jhinzzz/ScreenForge
@@ -0,0 +1 @@
1
+ __version__ = "0.6.0"
@@ -25,6 +25,7 @@ DEFAULT_PLAYGROUND_URL = "http://127.0.0.1:7860"
25
25
  # waits for the last frame to land before sys.exit — kept ≤ read+ε and well under
26
26
  # human-perceptible. Worst added latency on --action ≈ _JOIN_TIMEOUT.
27
27
  _POST_TIMEOUT = (0.2, 0.25) # (connect, read) seconds
28
+ _DOM_POST_TIMEOUT = (0.2, 0.4) # tree body is larger; read budget a touch higher
28
29
  _JOIN_TIMEOUT = 0.3 # seconds; single-step last-frame grace
29
30
 
30
31
 
@@ -42,6 +43,7 @@ class PlaygroundStepEvent(BaseModel):
42
43
  success: bool = True
43
44
  screenshot_b64: str = "" # empty = no screenshot this step (degrade, never crash)
44
45
  file_path: str = "" # abs path of the generated test file (for "open in IDE")
46
+ has_dom_tree: bool = False # a sidecar DOM tree was captured for this step
45
47
 
46
48
 
47
49
  class PlaygroundSink:
@@ -101,6 +103,50 @@ class PlaygroundSink:
101
103
  log.debug(f"[playground-sink] screenshot skip: {e}")
102
104
  return ""
103
105
 
106
+ @staticmethod
107
+ def capture_dom_tree(adapter, platform: str) -> dict | None:
108
+ """Sidecar hierarchical tree from the SAME raw source the compressors use,
109
+ without touching them. Any failure → None (degrade, never crash the action).
110
+
111
+ web: build_web_tree(adapter.driver) (Playwright page.evaluate)
112
+ android: build_mobile_tree(driver.dump_hierarchy(), 'android')
113
+ ios: build_mobile_tree(driver.source(), 'ios')
114
+ """
115
+ try:
116
+ from playground.dom_capture import build_mobile_tree, build_web_tree
117
+
118
+ driver = adapter.driver
119
+ if platform == "web":
120
+ return build_web_tree(driver)
121
+ if platform == "android":
122
+ return build_mobile_tree(driver.dump_hierarchy(), "android")
123
+ if platform == "ios":
124
+ return build_mobile_tree(driver.source(), "ios")
125
+ return None
126
+ except Exception as e:
127
+ log.debug(f"[playground-sink] dom capture skip: {e}")
128
+ return None
129
+
130
+ def push_dom_tree(self, run_id: str, step_index: int, tree: dict) -> None:
131
+ """Fire-and-forget POST of the captured tree, DECOUPLED from push_step and
132
+ NEVER join-waited — a big tree body must never delay the lean step push or
133
+ the action's exit. Disabled sink → no-op."""
134
+ if not self.enabled:
135
+ return
136
+ threading.Thread(
137
+ target=self._post_dom, args=(run_id, step_index, tree), daemon=True
138
+ ).start()
139
+
140
+ def _post_dom(self, run_id: str, step_index: int, tree: dict) -> None:
141
+ try:
142
+ requests.post(
143
+ f"{self.base_url}/api/dom",
144
+ json={"run_id": run_id, "step_index": step_index, "tree": tree},
145
+ timeout=_DOM_POST_TIMEOUT,
146
+ )
147
+ except Exception as e: # swallow (G5)
148
+ log.debug(f"[playground-sink] dom skip (unreachable): {e}")
149
+
104
150
 
105
151
  def build_step_event(
106
152
  *,
@@ -110,6 +156,7 @@ def build_step_event(
110
156
  result: dict,
111
157
  screenshot_b64: str,
112
158
  file_path: str = "",
159
+ has_dom_tree: bool = False,
113
160
  ) -> PlaygroundStepEvent:
114
161
  """MANDATORY single construction point for every step event (code#4).
115
162
 
@@ -134,6 +181,7 @@ def build_step_event(
134
181
  success=bool(result.get("success", True)),
135
182
  screenshot_b64=screenshot_b64,
136
183
  file_path=os.path.abspath(file_path) if file_path else "",
184
+ has_dom_tree=has_dom_tree,
137
185
  )
138
186
 
139
187
 
@@ -168,15 +216,20 @@ def maybe_push_step(
168
216
  return
169
217
  try:
170
218
  run_key, resolved_index = resolve_playground_run_key(args, reporter)
219
+ idx = step_index if step_index is not None else resolved_index
220
+ tree = sink.capture_dom_tree(adapter, getattr(args, "platform", ""))
171
221
  event = build_step_event(
172
222
  run_key=run_key,
173
- step_index=step_index if step_index is not None else resolved_index,
223
+ step_index=idx,
174
224
  action_data=action_data,
175
225
  result=result,
176
226
  screenshot_b64=PlaygroundSink.encode_screenshot(adapter),
177
227
  file_path=file_path,
228
+ has_dom_tree=tree is not None,
178
229
  )
179
230
  sink.push_step(event)
231
+ if tree is not None:
232
+ sink.push_dom_tree(run_key, idx, tree) # decoupled, never joined
180
233
  except Exception as e: # never let visualization break the observed action
181
234
  log.debug(f"[playground-sink] push skipped: {e}")
182
235
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "screenforge"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "AI-driven cross-platform UI automation engine with test script generation"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenforge
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: AI-driven cross-platform UI automation engine with test script generation
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jhinzzz/ScreenForge
@@ -71,6 +71,7 @@ tests/test_cli_action_json.py
71
71
  tests/test_codegen_quality.py
72
72
  tests/test_dispatch.py
73
73
  tests/test_doctor_orphan_browser.py
74
+ tests/test_dom_capture.py
74
75
  tests/test_error_codes.py
75
76
  tests/test_executor.py
76
77
  tests/test_failure_diagnosis.py
@@ -0,0 +1,136 @@
1
+ """Tests for playground/dom_capture.py — the sidecar HIERARCHICAL tree builders.
2
+
3
+ Unlike utils/utils_xml.py (which flattens for the LLM), these preserve parent/
4
+ child so the playground can render a real tree. They REUSE utils_xml predicates
5
+ (never modify them) and degrade to None on any failure (never crash the sink).
6
+ """
7
+
8
+ from playground.dom_capture import build_mobile_tree, build_web_tree # noqa: F401
9
+
10
+
11
+ class TestBuildMobileTree:
12
+ def test_returns_none_on_parse_error(self):
13
+ assert build_mobile_tree("<<not xml", "android") is None
14
+
15
+ def test_empty_hierarchy_yields_none(self):
16
+ # An empty hierarchy has no surviving elements ⇒ no tree (keeps has_dom_tree
17
+ # truthful: the pip must not light when there is nothing to show).
18
+ xml = '<hierarchy rotation="0"></hierarchy>'
19
+ assert build_mobile_tree(xml, "android") is None
20
+
21
+ def test_ios_xcuitest_xml_yields_none_today(self):
22
+ # iOS WDA returns XCUITest XML (XCUIElementType* tags + name/label/value/type),
23
+ # which the Android predicates don't recognize → every node filtered → None.
24
+ # This pins the HONEST v1 boundary: iOS DOM-tree capture is not yet supported.
25
+ ios_xml = (
26
+ '<XCUIElementTypeApplication name="MyApp" label="MyApp">'
27
+ '<XCUIElementTypeButton name="login" label="登录" value="" type="XCUIElementTypeButton"/>'
28
+ '</XCUIElementTypeApplication>'
29
+ )
30
+ assert build_mobile_tree(ios_xml, "ios") is None
31
+
32
+ def test_single_clickable_node_emitted(self):
33
+ xml = (
34
+ '<hierarchy rotation="0">'
35
+ '<node class="android.widget.Button" text="Login" clickable="true"/>'
36
+ '</hierarchy>'
37
+ )
38
+ tree = build_mobile_tree(xml, "android")
39
+ assert tree["platform"] == "android"
40
+ assert len(tree["nodes"]) == 1
41
+ n = tree["nodes"][0]
42
+ assert n["class"] == "Button"
43
+ assert n["text"] == "Login"
44
+ assert n["clickable"] is True
45
+ assert n["children"] == []
46
+
47
+ def test_hierarchy_is_preserved_not_flattened(self):
48
+ # A clickable container with a labeled child: the tree keeps the nesting
49
+ # (the FLAT compressor would emit them as siblings; we must not).
50
+ xml = (
51
+ '<hierarchy rotation="0">'
52
+ '<node class="android.widget.LinearLayout" text="Settings" clickable="true">'
53
+ ' <node class="android.widget.TextView" text="Wi-Fi"/>'
54
+ '</node>'
55
+ '</hierarchy>'
56
+ )
57
+ tree = build_mobile_tree(xml, "android")
58
+ assert len(tree["nodes"]) == 1
59
+ parent = tree["nodes"][0]
60
+ assert parent["text"] == "Settings"
61
+ assert len(parent["children"]) == 1
62
+ assert parent["children"][0]["text"] == "Wi-Fi"
63
+
64
+ def test_dead_wrapper_collapses_lifting_children(self):
65
+ # A non-surviving wrapper (no text/desc/clickable/disabled) must NOT appear;
66
+ # its surviving child lifts to the wrapper's parent level.
67
+ xml = (
68
+ '<hierarchy rotation="0">'
69
+ '<node class="android.widget.FrameLayout">'
70
+ ' <node class="android.widget.Button" text="OK" clickable="true"/>'
71
+ '</node>'
72
+ '</hierarchy>'
73
+ )
74
+ tree = build_mobile_tree(xml, "android")
75
+ assert len(tree["nodes"]) == 1
76
+ assert tree["nodes"][0]["text"] == "OK" # lifted, wrapper gone
77
+
78
+ def test_disabled_emitted_without_clickable(self):
79
+ xml = (
80
+ '<hierarchy rotation="0">'
81
+ '<node class="android.widget.Button" text="Send" enabled="false"/>'
82
+ '</hierarchy>'
83
+ )
84
+ tree = build_mobile_tree(xml, "android")
85
+ n = tree["nodes"][0]
86
+ assert n["disabled"] is True
87
+ assert "clickable" not in n
88
+
89
+ def test_full_resource_id_emitted(self):
90
+ xml = (
91
+ '<hierarchy rotation="0">'
92
+ '<node class="android.widget.Button" text="Go" clickable="true" '
93
+ 'resource-id="com.app:id/go_btn"/>'
94
+ '</hierarchy>'
95
+ )
96
+ tree = build_mobile_tree(xml, "android")
97
+ assert tree["nodes"][0]["id"] == "com.app:id/go_btn"
98
+
99
+ def test_no_ref_and_no_bbox_on_mobile(self):
100
+ xml = (
101
+ '<hierarchy rotation="0">'
102
+ '<node class="android.widget.Button" text="X" clickable="true" '
103
+ 'bounds="[0,0][100,50]"/>'
104
+ '</hierarchy>'
105
+ )
106
+ n = build_mobile_tree(xml, "android")["nodes"][0]
107
+ assert "ref" not in n
108
+ assert "x" not in n and "w" not in n # honest: mobile has no bbox in this shape
109
+
110
+
111
+ class _FakePage:
112
+ def __init__(self, result=None, raises=None):
113
+ self._result = result
114
+ self._raises = raises
115
+
116
+ def evaluate(self, _js):
117
+ if self._raises:
118
+ raise self._raises
119
+ return self._result
120
+
121
+
122
+ class TestBuildWebTree:
123
+ def test_passthrough_wellformed_result(self):
124
+ page = _FakePage(result={"nodes": [{"ref": "@1", "class": "button", "children": []}]})
125
+ tree = build_web_tree(page)
126
+ assert tree["platform"] == "web"
127
+ assert tree["nodes"][0]["ref"] == "@1"
128
+
129
+ def test_none_when_result_missing_nodes(self):
130
+ assert build_web_tree(_FakePage(result={"oops": 1})) is None
131
+
132
+ def test_none_when_result_not_a_dict(self):
133
+ assert build_web_tree(_FakePage(result="not a dict")) is None
134
+
135
+ def test_none_when_evaluate_raises(self):
136
+ assert build_web_tree(_FakePage(raises=RuntimeError("page closed"))) is None
@@ -311,3 +311,75 @@ class TestOpenInEditor:
311
311
  "file_path": str(f), "line": "not-a-number", "editor": "trae"})
312
312
  assert resp.json()["ok"] is True
313
313
  assert captured["cmd"] == ["/usr/local/bin/trae", "-g", f"{f}:1"]
314
+
315
+
316
+ class TestDomStore:
317
+ def test_post_dom_persists_and_get_returns_it(self, client, tmp_path):
318
+ app_module._DOM_DIR = tmp_path # redirect store to a temp dir
319
+ app_module._dom_index.clear()
320
+ tree = {"platform": "web", "nodes": [{"ref": "@1", "class": "button", "children": []}]}
321
+ r = client.post("/api/dom", json={"run_id": "s1", "step_index": 2, "tree": tree})
322
+ assert r.status_code == 200 and r.json()["ok"] is True
323
+ got = client.get("/api/run/s1/step/2/dom")
324
+ assert got.status_code == 200
325
+ assert got.json()["nodes"][0]["ref"] == "@1"
326
+
327
+ def test_get_absent_returns_404(self, client, tmp_path):
328
+ app_module._DOM_DIR = tmp_path
329
+ app_module._dom_index.clear()
330
+ assert client.get("/api/run/nope/step/9/dom").status_code == 404
331
+
332
+ def test_lru_evicts_oldest_run_dir(self, client, tmp_path):
333
+ app_module._DOM_DIR = tmp_path
334
+ app_module._dom_index.clear()
335
+ app_module._MAX_DOM_RUNS = 2
336
+ for rid in ("a", "b", "c"): # 3 runs, cap 2 → 'a' evicted
337
+ client.post("/api/dom", json={"run_id": rid, "step_index": 1,
338
+ "tree": {"platform": "web", "nodes": []}})
339
+ assert client.get("/api/run/a/step/1/dom").status_code == 404
340
+ assert client.get("/api/run/c/step/1/dom").status_code == 200
341
+
342
+ def test_dotdot_run_id_cannot_escape_dom_dir(self, client, tmp_path):
343
+ app_module._DOM_DIR = tmp_path
344
+ app_module._dom_index.clear()
345
+ # '..'/'.' run_ids must collapse to the 'run' fallback — a single
346
+ # component INSIDE _DOM_DIR — not resolve to the store's parent.
347
+ for evil in ("..", "."):
348
+ resolved = app_module._dom_run_dir(evil)
349
+ assert resolved.parent.resolve() == tmp_path.resolve()
350
+ assert resolved.resolve() == (tmp_path / "run").resolve()
351
+ # a POST with run_id='..' writes inside tmp_path, never tmp_path.parent
352
+ r = client.post("/api/dom", json={"run_id": "..", "step_index": 1,
353
+ "tree": {"platform": "web", "nodes": []}})
354
+ assert r.json()["ok"] is True
355
+ # the file landed under tmp_path (the 'run' fallback), not tmp_path.parent
356
+ assert not (tmp_path.parent / "step_001.json").exists()
357
+ assert (tmp_path / "run" / "step_001.json").exists()
358
+
359
+ def test_cjk_text_round_trips_through_dom_store(self, client, tmp_path):
360
+ app_module._DOM_DIR = tmp_path
361
+ app_module._dom_index.clear()
362
+ tree = {"platform": "web", "nodes": [{"ref": "@1", "class": "button", "text": "登录", "children": []}]}
363
+ client.post("/api/dom", json={"run_id": "cjk", "step_index": 1, "tree": tree})
364
+ got = client.get("/api/run/cjk/step/1/dom")
365
+ assert got.status_code == 200
366
+ assert got.json()["nodes"][0]["text"] == "登录"
367
+
368
+
369
+ class TestHasDomTreePassthrough:
370
+ def test_step_event_carries_has_dom_tree_to_sse(self, client):
371
+ import asyncio
372
+
373
+ q: asyncio.Queue = asyncio.Queue()
374
+ app_module._subscribers.append(q)
375
+ try:
376
+ body = _step_body(run_id="r1", step_index=1)
377
+ body["has_dom_tree"] = True
378
+ resp = client.post("/api/step", json=body)
379
+ assert resp.status_code == 200
380
+ evt = q.get_nowait()
381
+ finally:
382
+ app_module._subscribers.remove(q)
383
+
384
+ assert evt["type"] == "step"
385
+ assert evt.get("has_dom_tree") is True
@@ -6,6 +6,7 @@ cross-process run-key resolution (arch#1) and daemon non-blocking (arch#3).
6
6
  """
7
7
 
8
8
  import argparse
9
+ import threading
9
10
  import time
10
11
  from unittest.mock import MagicMock, patch
11
12
 
@@ -421,3 +422,165 @@ class TestMaybePushStep:
421
422
  result=_result(),
422
423
  step_index=1,
423
424
  )
425
+
426
+ def test_enabled_pushes_dom_tree_when_capture_succeeds(self):
427
+ """End-to-end wiring: a captured tree flows to push_dom_tree with the
428
+ resolved (run_key, idx, tree), and the step event advertises has_dom_tree."""
429
+ sink = PlaygroundSink(enabled=True)
430
+ adapter = MagicMock()
431
+ args = argparse.Namespace(session_id="", session_end="", platform="web")
432
+ reporter = MagicMock(run_id="r1")
433
+ fake_tree = {
434
+ "platform": "web",
435
+ "nodes": [{"ref": "@1", "class": "button", "children": []}],
436
+ }
437
+ with patch.object(
438
+ PlaygroundSink, "capture_dom_tree", return_value=fake_tree
439
+ ), patch.object(
440
+ PlaygroundSink, "encode_screenshot", return_value=""
441
+ ), patch.object(sink, "push_step") as mock_push_step, patch.object(
442
+ sink, "push_dom_tree"
443
+ ) as mock_push_dom:
444
+ maybe_push_step(
445
+ sink,
446
+ args=args,
447
+ reporter=reporter,
448
+ adapter=adapter,
449
+ action_data=_action_data(),
450
+ result=_result(),
451
+ step_index=3,
452
+ )
453
+ mock_push_step.assert_called_once()
454
+ mock_push_dom.assert_called_once()
455
+ # the step event must advertise the tree
456
+ assert mock_push_step.call_args[0][0].has_dom_tree is True
457
+ # push_dom_tree gets (run_key, idx, tree)
458
+ called = mock_push_dom.call_args[0]
459
+ assert called[0] == "r1"
460
+ assert called[1] == 3
461
+ assert called[2] == fake_tree
462
+
463
+ def test_enabled_skips_dom_tree_when_capture_returns_none(self):
464
+ """Mirror: capture degraded to None → no tree POST, flag stays False, but
465
+ the lean step push still fires (the sink keeps observing without a tree)."""
466
+ sink = PlaygroundSink(enabled=True)
467
+ adapter = MagicMock()
468
+ args = argparse.Namespace(session_id="", session_end="", platform="web")
469
+ reporter = MagicMock(run_id="r1")
470
+ with patch.object(
471
+ PlaygroundSink, "capture_dom_tree", return_value=None
472
+ ), patch.object(
473
+ PlaygroundSink, "encode_screenshot", return_value=""
474
+ ), patch.object(sink, "push_step") as mock_push_step, patch.object(
475
+ sink, "push_dom_tree"
476
+ ) as mock_push_dom:
477
+ maybe_push_step(
478
+ sink,
479
+ args=args,
480
+ reporter=reporter,
481
+ adapter=adapter,
482
+ action_data=_action_data(),
483
+ result=_result(),
484
+ step_index=3,
485
+ )
486
+ mock_push_step.assert_called_once()
487
+ mock_push_dom.assert_not_called()
488
+ assert mock_push_step.call_args[0][0].has_dom_tree is False
489
+
490
+ def test_step_and_dom_tree_share_run_key_and_index(self):
491
+ # seam#2 regression: the SSE step event and the /api/dom push MUST use the
492
+ # SAME key + index from one maybe_push_step. With a --session-id the key is
493
+ # the session_id (NOT reporter.run_id) and the index is session steps()+1.
494
+ import argparse
495
+ sink = PlaygroundSink(enabled=True)
496
+ reporter = MagicMock()
497
+ reporter.run_id = "RID"
498
+ args = argparse.Namespace(platform="web", session_id="SESS", session_end="", playground_url="")
499
+ fake_tree = {"platform": "web", "nodes": [{"ref": "@1", "class": "button", "children": []}]}
500
+ captured = {}
501
+
502
+ def _grab_step(ev):
503
+ captured["step_run_id"] = ev.run_id
504
+ captured["step_idx"] = ev.step_index
505
+ with patch.object(PlaygroundSink, "capture_dom_tree", return_value=fake_tree), \
506
+ patch.object(PlaygroundSink, "encode_screenshot", return_value=""), \
507
+ patch("cli.playground_sink.load_session", return_value={"steps": 4}), \
508
+ patch.object(sink, "push_step", side_effect=_grab_step), \
509
+ patch.object(sink, "push_dom_tree") as mock_dom:
510
+ maybe_push_step(sink, args=args, reporter=reporter, adapter=MagicMock(),
511
+ action_data=_action_data(), result=_result())
512
+ dom_run_id, dom_idx, _tree = mock_dom.call_args[0]
513
+ # the SSE step event and the /api/dom push MUST use the same key + index
514
+ assert dom_run_id == captured["step_run_id"] == "SESS" # session_id is the run_key, not reporter.run_id
515
+ assert dom_idx == captured["step_idx"] == 5 # session steps(4)+1
516
+
517
+
518
+ class TestCaptureDomTree:
519
+ def test_capture_returns_none_when_capture_raises(self):
520
+ from cli.playground_sink import PlaygroundSink
521
+
522
+ class _Adapter:
523
+ driver = object()
524
+
525
+ # platform 'web' path lazy-imports build_web_tree; force a failure by
526
+ # passing an adapter whose driver.evaluate doesn't exist → swallowed → None.
527
+ assert PlaygroundSink.capture_dom_tree(_Adapter(), "web") is None
528
+
529
+ def test_has_dom_tree_flag_defaults_false(self):
530
+ from cli.playground_sink import PlaygroundStepEvent
531
+
532
+ ev = PlaygroundStepEvent(run_id="r1", step_index=1)
533
+ assert ev.has_dom_tree is False
534
+
535
+ def test_disabled_sink_never_posts_tree(self):
536
+ import cli.playground_sink as mod
537
+ from cli.playground_sink import PlaygroundSink
538
+
539
+ sink = PlaygroundSink(enabled=False)
540
+ with patch.object(mod.requests, "post") as mock_post:
541
+ sink.push_dom_tree("r1", 1, {"platform": "web", "nodes": []})
542
+ mock_post.assert_not_called()
543
+
544
+ def test_push_dom_tree_returns_immediately_under_slow_post(self):
545
+ """Red line: the tree POST is fire-and-forget with NO join, so even a very
546
+ slow playground cannot delay the action's exit. Mirrors the timing pattern
547
+ of TestDaemonNonBlocking — the 2s sleep rides the daemon thread, the caller
548
+ returns near-instantly. (push_dom_tree, unlike push_step, never join-waits.)"""
549
+ import cli.playground_sink as mod
550
+ from cli.playground_sink import PlaygroundSink
551
+
552
+ sink = PlaygroundSink(enabled=True)
553
+
554
+ def _slow_post(*a, **k):
555
+ time.sleep(2.0)
556
+ return MagicMock()
557
+
558
+ with patch.object(mod.requests, "post", side_effect=_slow_post):
559
+ start = time.perf_counter()
560
+ sink.push_dom_tree("r1", 1, {"platform": "web", "nodes": []})
561
+ elapsed = time.perf_counter() - start
562
+ # Daemon thread carries the 2s sleep; the caller returns at once.
563
+ assert elapsed < 0.3, f"push_dom_tree blocked for {elapsed:.2f}s"
564
+
565
+ def test_push_dom_tree_swallows_post_errors_in_thread(self):
566
+ """G5: a ConnectionError inside the daemon thread must be swallowed, never
567
+ propagate. We join the spawned thread (no .join() exists in production —
568
+ the test does it itself) so the error has actually fired before asserting."""
569
+ import cli.playground_sink as mod
570
+ from cli.playground_sink import PlaygroundSink
571
+
572
+ sink = PlaygroundSink(enabled=True)
573
+ before = threading.active_count()
574
+ with patch.object(
575
+ mod.requests,
576
+ "post",
577
+ side_effect=mod.requests.exceptions.ConnectionError("refused"),
578
+ ):
579
+ # Must not raise on the caller's thread.
580
+ sink.push_dom_tree("r1", 1, {"platform": "web", "nodes": []})
581
+ # Drain the daemon thread(s) push_dom_tree spawned so the swallow runs.
582
+ for t in threading.enumerate():
583
+ if t is not threading.current_thread() and t.daemon:
584
+ t.join(timeout=2.0)
585
+ # No thread leaked and, crucially, no exception surfaced.
586
+ assert threading.active_count() <= before
@@ -735,3 +735,87 @@ def test_did_you_mean_offers_close_match_on_typo(page):
735
735
  )
736
736
  # The suggested locator should be a web ref the agent can retry with.
737
737
  assert diag.candidates[0].locator["type"] == "ref"
738
+
739
+
740
+ # --- sidecar/compressor parity: cursor:pointer clickability ----------------
741
+ #
742
+ # These exercise playground/dom_capture.build_web_tree (the "Brain's Eye View"
743
+ # sidecar), NOT compress_web_dom. The sidecar's whole job is to show the panel
744
+ # what the brain PERCEIVED — so its interactivity predicate must match the
745
+ # compressor's. utils_web.py treats style.cursor==='pointer' as a clickability
746
+ # signal; the sidecar originally did not, so cursor:pointer "fake buttons" (the
747
+ # div/span-as-button pattern) showed up in the panel mislabeled clickable=false
748
+ # — the panel lying about what the brain saw. That's the bug these pin.
749
+
750
+
751
+ def _web_tree_flat(page, html: str) -> list:
752
+ """Render HTML, build the sidecar hierarchical tree, and flatten it to a list
753
+ so a test can find a node regardless of nesting depth (the sidecar emits a
754
+ parent/child forest, unlike compress_web_dom's flat ui_elements)."""
755
+ from playground.dom_capture import build_web_tree
756
+
757
+ page.goto("data:text/html," + _quote(html))
758
+ page.wait_for_timeout(200)
759
+ tree = build_web_tree(page)
760
+ flat: list = []
761
+
762
+ def _walk(nodes):
763
+ for n in nodes:
764
+ flat.append(n)
765
+ _walk(n.get("children") or [])
766
+
767
+ _walk((tree or {}).get("nodes") or [])
768
+ return flat
769
+
770
+
771
+ def test_cursor_pointer_div_is_clickable_in_sidecar(page):
772
+ """A div-as-button (no native tag / role / onclick, only style:cursor:pointer)
773
+ is what the brain SEES as clickable — compress_web_dom marks it clickable via
774
+ style.cursor==='pointer' (utils_web.py). The sidecar tree must agree, or the
775
+ Brain's Eye panel under-reports the brain's clickable set (the pionex finding:
776
+ panel showed fewer clickables than the brain perceived). Pins parity."""
777
+ nodes = _web_tree_flat(page, "<div style='cursor:pointer'>FakeButton</div>")
778
+ fake = next((n for n in nodes if (n.get("text") or "") == "FakeButton"), None)
779
+ assert fake is not None, (
780
+ "cursor:pointer div not captured at all — sidecar blind to a div-as-button"
781
+ )
782
+ assert fake.get("clickable") is True, (
783
+ "cursor:pointer div reported clickable=false in the sidecar — the Brain's Eye "
784
+ "panel under-counts what the brain actually perceived as clickable "
785
+ "(compress_web_dom counts it via style.cursor==='pointer'); parity broken"
786
+ )
787
+
788
+
789
+ def test_plain_text_div_not_clickable_in_sidecar(page):
790
+ """Over-correction guard: a plain text div (default cursor, no interactivity
791
+ signal) is still captured for its text, but must stay clickable=false. Pins
792
+ that the cursor:pointer fix keys on 'pointer' specifically, not 'has any text'."""
793
+ nodes = _web_tree_flat(page, "<div>JustText</div>")
794
+ plain = next((n for n in nodes if (n.get("text") or "") == "JustText"), None)
795
+ assert plain is not None, "plain text div should still be captured (for its label)"
796
+ assert plain.get("clickable") is not True, (
797
+ "plain text div (cursor:auto) wrongly marked clickable — the cursor:pointer "
798
+ "fix over-reached and now flags any text node as clickable"
799
+ )
800
+
801
+
802
+ def test_inert_cursor_pointer_div_not_clickable_in_sidecar(page):
803
+ """Parity at the inert∩cursor:pointer intersection. The compressor gates
804
+ interactivity on `!isInert` (utils_web.py: `isInteractive = !isDisabled &&
805
+ !isInert && (… || cursor==='pointer')`), so a cursor:pointer div inside an
806
+ inert subtree (the open-<dialog> backdrop pattern) is clickable=false to the
807
+ brain. The sidecar must agree — otherwise the cursor:pointer fix REINTRODUCES
808
+ the 'panel lies about what the brain saw' bug in the opposite direction
809
+ (over-reporting a dead control behind a modal as clickable). The node is still
810
+ captured and carries the inert flag; only `clickable` must be false."""
811
+ nodes = _web_tree_flat(
812
+ page, "<div inert><div style='cursor:pointer'>BehindModal</div></div>"
813
+ )
814
+ behind = next((n for n in nodes if (n.get("text") or "") == "BehindModal"), None)
815
+ assert behind is not None, "inert cursor:pointer div should still be captured"
816
+ assert behind.get("inert") is True, "node inside an inert subtree must carry the inert flag"
817
+ assert behind.get("clickable") is not True, (
818
+ "inert cursor:pointer div reported clickable=true in the sidecar — diverges "
819
+ "from the compressor (which gates isInteractive on !isInert); the panel would "
820
+ "show a dead control behind a modal as clickable"
821
+ )
@@ -1 +0,0 @@
1
- __version__ = "0.5.0"
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