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.
- {screenforge-0.5.0/screenforge.egg-info → screenforge-0.6.0}/PKG-INFO +1 -1
- screenforge-0.6.0/cli/_version.py +1 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/playground_sink.py +54 -1
- {screenforge-0.5.0 → screenforge-0.6.0}/pyproject.toml +1 -1
- {screenforge-0.5.0 → screenforge-0.6.0/screenforge.egg-info}/PKG-INFO +1 -1
- {screenforge-0.5.0 → screenforge-0.6.0}/screenforge.egg-info/SOURCES.txt +1 -0
- screenforge-0.6.0/tests/test_dom_capture.py +136 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_playground_app.py +72 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_playground_sink.py +163 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_web_dom_complex_live.py +84 -0
- screenforge-0.5.0/cli/_version.py +0 -1
- {screenforge-0.5.0 → screenforge-0.6.0}/LICENSE +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/README.md +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/__init__.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/dispatch.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/doctor.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/__init__.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/action.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/default.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/demo.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/dry_run.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/init.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/plan.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/modes/workflow.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/parser.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/reporter.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/session.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/shared.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/shorthand.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/cli/tool_protocol_handlers.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/__init__.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/adapters/__init__.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/adapters/android_adapter.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/adapters/base_adapter.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/adapters/ios_adapter.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/adapters/web_adapter.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/ai.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/ai_autonomous.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/ai_heal.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/__init__.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/cache_hash.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/cache_manager.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/cache_stats.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/cache_storage.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/cache/embedding_loader.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/capabilities.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/case_memory.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/error_codes.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/exceptions.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/executor.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/failure_diagnosis.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/history_manager.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/logs.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/mcp_server.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/preflight.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/progress.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/run_reporter.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/run_resume.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/runtime_modes.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/tool_protocol.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/visual_fallback.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/common/workflow_schema.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/config/__init__.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/config/config.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/config/env_loader.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/screenforge.egg-info/dependency_links.txt +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/screenforge.egg-info/entry_points.txt +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/screenforge.egg-info/requires.txt +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/screenforge.egg-info/top_level.txt +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/setup.cfg +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_ai_autonomous.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_ai_brain.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_ai_heal.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_android_smoke_live.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_cache_manager.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_capabilities.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_cli_action_json.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_codegen_quality.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_dispatch.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_doctor_orphan_browser.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_error_codes.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_executor.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_failure_diagnosis.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_interaction_actions.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_ios_smoke_live.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_mcp_ref_cache.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_ml_optional.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_parser.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_playground_sink_integration.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_run_reporter.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_run_resume.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_runtime_modes.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_screenshot_annotator.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_shorthand.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_tool_protocol_diagnosis.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_utils_ios.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_utils_web.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_utils_xml.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_visual_fallback.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_web_adapter.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/tests/test_web_smoke_live.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/utils/__init__.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/utils/screenshot_annotator.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/utils/utils_ios.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/utils/utils_web.py +0 -0
- {screenforge-0.5.0 → screenforge-0.6.0}/utils/utils_xml.py +0 -0
|
@@ -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=
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|