screenforge 0.7.0__tar.gz → 0.10.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.7.0 → screenforge-0.10.0}/PKG-INFO +3 -1
- {screenforge-0.7.0 → screenforge-0.10.0}/README.md +2 -0
- screenforge-0.10.0/cli/_version.py +1 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/action.py +11 -6
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/tool_protocol_handlers.py +44 -17
- {screenforge-0.7.0 → screenforge-0.10.0}/common/case_memory.py +14 -2
- {screenforge-0.7.0 → screenforge-0.10.0}/common/failure_diagnosis.py +40 -4
- {screenforge-0.7.0 → screenforge-0.10.0}/common/mcp_server.py +25 -7
- screenforge-0.10.0/common/observation.py +48 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/tool_protocol.py +6 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/pyproject.toml +1 -1
- {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/PKG-INFO +3 -1
- {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/SOURCES.txt +11 -0
- screenforge-0.10.0/tests/test_case_memory_identity.py +93 -0
- screenforge-0.10.0/tests/test_failure_diagnosis_ios.py +62 -0
- screenforge-0.10.0/tests/test_failure_diagnosis_mobile.py +62 -0
- screenforge-0.10.0/tests/test_forward_candidates.py +61 -0
- screenforge-0.10.0/tests/test_inspect_ui_candidates.py +67 -0
- screenforge-0.10.0/tests/test_inspect_ui_envelope.py +92 -0
- screenforge-0.10.0/tests/test_inspect_ui_memory.py +116 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_mcp_ref_cache.py +1 -1
- screenforge-0.10.0/tests/test_mcp_tool_result.py +68 -0
- screenforge-0.10.0/tests/test_observation.py +76 -0
- screenforge-0.10.0/tests/test_observation_action_parity.py +59 -0
- screenforge-0.7.0/cli/_version.py +0 -1
- {screenforge-0.7.0 → screenforge-0.10.0}/LICENSE +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/dispatch.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/doctor.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/default.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/demo.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/dry_run.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/init.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/plan.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/workflow.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/parser.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/playground_sink.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/reporter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/session.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/shared.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/cli/shorthand.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/adapters/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/adapters/android_adapter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/adapters/base_adapter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/adapters/ios_adapter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/adapters/web_adapter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/ai.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/ai_autonomous.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/ai_heal.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/cache_hash.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/cache_manager.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/cache_stats.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/cache_storage.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/embedding_loader.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/capabilities.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/error_codes.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/exceptions.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/executor.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/history_manager.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/logs.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/preflight.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/progress.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/run_reporter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/run_resume.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/runtime_modes.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/visual_fallback.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/common/workflow_schema.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/config/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/config/config.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/config/env_loader.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/dependency_links.txt +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/entry_points.txt +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/requires.txt +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/top_level.txt +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/setup.cfg +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_action_mode_observation_stash.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_ai_autonomous.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_ai_brain.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_ai_heal.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_android_smoke_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_cache_hash.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_cache_manager.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_capabilities.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_cli_action_json.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_codegen_quality.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_dispatch.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_doctor_orphan_browser.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_dom_capture.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_error_codes.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_executor.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_failure_diagnosis.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_interaction_actions.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_ios_smoke_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_mcp_execute_observation.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_mcp_execute_parity_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_ml_optional.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_mode_error_code_threading.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_parser.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_playground_app.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_playground_sink.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_playground_sink_integration.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_cases.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_conftest_gating.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_filmstrip.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_html.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_json.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_locate_frame.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_patching.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_recorder.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_screenshot.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_smoke_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_run_reporter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_run_resume.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_runtime_modes.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_screenshot_annotator.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_shared_observation_stash.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_shorthand.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_tool_protocol_diagnosis.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_utils_ios.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_utils_web.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_utils_xml.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_visual_fallback.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_web_adapter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_web_dom_complex_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_web_smoke_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_workflow_mode_observation_stash.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/utils/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/utils/screenshot_annotator.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/utils/utils_ios.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/utils/utils_web.py +0 -0
- {screenforge-0.7.0 → screenforge-0.10.0}/utils/utils_xml.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: screenforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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
|
|
@@ -120,6 +120,8 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
|
|
|
120
120
|
- **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
|
|
121
121
|
- **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
|
|
122
122
|
- **Execution-replay review report**: Run any test with `REVIEW_RECORD=1` and get a self-contained, shareable offline `report.html` — a drag-timeline of each step's screenshot + the test source line that triggered it + the DOM tree, plus a `video.gif` filmstrip. Default off, zero overhead. (Web verified; mobile registry/recording seam in place but unverified.)
|
|
123
|
+
- **Case memory hints on `inspect_ui`**: pass an optional `task` label and the payload's `memory` field carries what the last run of that task did — locator, counts, and outcome — a hint the Agent weighs against the live tree, seen before it acts, never auto-replayed. It records failures too, so check `last_status`/`success_count` before trusting a hint. The label must match the one the earlier run recorded (`--action-name`, else `"{action}:{locator_value}"`); `load_case_memory` lists them. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
|
|
124
|
+
- **Plain-language element lookup on `inspect_ui`**: pass an optional `intent` phrase (e.g. `"登录按钮"`) and the payload's `candidates` field returns up to 3 ranked elements with ready-to-use locators — additive to the full `ui_tree`, never a replacement. Matching is literal string similarity, not semantic, so phrase `intent` in the same language as the UI. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
|
|
123
125
|
|
|
124
126
|
## Agent Integration (Claude Code / Cursor / Codex)
|
|
125
127
|
|
|
@@ -75,6 +75,8 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
|
|
|
75
75
|
- **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
|
|
76
76
|
- **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
|
|
77
77
|
- **Execution-replay review report**: Run any test with `REVIEW_RECORD=1` and get a self-contained, shareable offline `report.html` — a drag-timeline of each step's screenshot + the test source line that triggered it + the DOM tree, plus a `video.gif` filmstrip. Default off, zero overhead. (Web verified; mobile registry/recording seam in place but unverified.)
|
|
78
|
+
- **Case memory hints on `inspect_ui`**: pass an optional `task` label and the payload's `memory` field carries what the last run of that task did — locator, counts, and outcome — a hint the Agent weighs against the live tree, seen before it acts, never auto-replayed. It records failures too, so check `last_status`/`success_count` before trusting a hint. The label must match the one the earlier run recorded (`--action-name`, else `"{action}:{locator_value}"`); `load_case_memory` lists them. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
|
|
79
|
+
- **Plain-language element lookup on `inspect_ui`**: pass an optional `intent` phrase (e.g. `"登录按钮"`) and the payload's `candidates` field returns up to 3 ranked elements with ready-to-use locators — additive to the full `ui_tree`, never a replacement. Matching is literal string similarity, not semantic, so phrase `intent` in the same language as the UI. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
|
|
78
80
|
|
|
79
81
|
## Agent Integration (Claude Code / Cursor / Codex)
|
|
80
82
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.10.0"
|
|
@@ -26,6 +26,7 @@ from cli.shared import (
|
|
|
26
26
|
save_to_disk,
|
|
27
27
|
)
|
|
28
28
|
from common.failure_diagnosis import diagnose
|
|
29
|
+
from common.observation import build_observation
|
|
29
30
|
from common.runtime_modes import MODE_RUN
|
|
30
31
|
|
|
31
32
|
|
|
@@ -69,9 +70,11 @@ def build_failure_payload(
|
|
|
69
70
|
ui_elements=ui_tree.get("ui_elements", []) or [],
|
|
70
71
|
)
|
|
71
72
|
payload.update(diag.to_dict())
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
observation = build_observation(ui_tree=ui_tree, current_url=current_url)
|
|
74
|
+
observation.pop("screenshot_base64") # engine_error has never carried one
|
|
75
|
+
observation.pop("memory") # decision-time hint; action is post-decision
|
|
76
|
+
observation.pop("candidates") # engine_error already has its own diagnosis candidates
|
|
77
|
+
payload.update(observation)
|
|
75
78
|
return payload
|
|
76
79
|
|
|
77
80
|
|
|
@@ -88,14 +91,16 @@ def build_success_payload(
|
|
|
88
91
|
Single source of truth shared by the shell --json stdout write and the MCP
|
|
89
92
|
observation stash, so the two can never drift. Pure: no I/O, no device.
|
|
90
93
|
"""
|
|
94
|
+
observation = build_observation(ui_tree=ui_tree, current_url=current_url)
|
|
95
|
+
observation.pop("screenshot_base64") # action payloads have never carried one
|
|
96
|
+
observation.pop("memory") # decision-time hint; action is post-decision
|
|
97
|
+
observation.pop("candidates") # engine_error already has its own diagnosis candidates
|
|
91
98
|
return {
|
|
92
99
|
"ok": True,
|
|
93
100
|
"action": action_name,
|
|
94
101
|
"platform": platform,
|
|
95
|
-
|
|
96
|
-
"element_count": len(ui_tree.get("ui_elements", []) or []),
|
|
102
|
+
**observation,
|
|
97
103
|
"output_script": output_script,
|
|
98
|
-
"current_url": current_url,
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
|
|
@@ -20,6 +20,8 @@ from cli.shared import (
|
|
|
20
20
|
current_url,
|
|
21
21
|
log,
|
|
22
22
|
)
|
|
23
|
+
from common.failure_diagnosis import rank_candidates
|
|
24
|
+
from common.observation import build_observation
|
|
23
25
|
from common.run_resume import RunContextLoadError, load_run_bundle
|
|
24
26
|
from common.runtime_modes import MODE_DOCTOR, resolve_execution_mode
|
|
25
27
|
from common.tool_protocol import (
|
|
@@ -137,6 +139,30 @@ def _find_case_memory_hit(args, execution_mode: str) -> dict | None:
|
|
|
137
139
|
)
|
|
138
140
|
|
|
139
141
|
|
|
142
|
+
def _lookup_task_memory(platform: str, task: str) -> dict:
|
|
143
|
+
"""Case-memory hint for an inspect_ui request, or {} when there's nothing.
|
|
144
|
+
|
|
145
|
+
A HINT, not an instruction — see common/observation.py. Matching is fuzzy by
|
|
146
|
+
design (find_entry ORs control_label with source_ref), so a wrong hit costs
|
|
147
|
+
one useless hint, never a wrong click. control_kind is "action" because an
|
|
148
|
+
inspect precedes a single action, and that's the kind those runs record under.
|
|
149
|
+
Any read failure degrades to {}: a missing hint must never cost the agent
|
|
150
|
+
its UI tree.
|
|
151
|
+
"""
|
|
152
|
+
if not str(task).strip():
|
|
153
|
+
return {}
|
|
154
|
+
try:
|
|
155
|
+
entry = _load_case_memory_store().find_entry(
|
|
156
|
+
platform=platform,
|
|
157
|
+
control_kind="action",
|
|
158
|
+
control_label=task,
|
|
159
|
+
)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
log.warning(f"⚠️ [Warning] Case-memory lookup failed, continuing without hint: {e}")
|
|
162
|
+
return {}
|
|
163
|
+
return entry or {}
|
|
164
|
+
|
|
165
|
+
|
|
140
166
|
def build_load_case_memory_payload(
|
|
141
167
|
platform: str = "",
|
|
142
168
|
control_kind: str = "",
|
|
@@ -182,13 +208,6 @@ def build_inspect_ui_payload(request, shared_adapter_manager: _SharedAdapterMana
|
|
|
182
208
|
1,
|
|
183
209
|
)
|
|
184
210
|
|
|
185
|
-
if not screenshot_base64:
|
|
186
|
-
try:
|
|
187
|
-
img_bytes = adapter.take_screenshot()
|
|
188
|
-
screenshot_base64 = base64.b64encode(img_bytes).decode("utf-8")
|
|
189
|
-
except Exception as e:
|
|
190
|
-
log.warning(f"⚠️ [Warning] inspect_ui screenshot capture failed: {e}")
|
|
191
|
-
|
|
192
211
|
try:
|
|
193
212
|
ui_tree = json.loads(ui_json)
|
|
194
213
|
except json.JSONDecodeError:
|
|
@@ -207,30 +226,36 @@ def build_inspect_ui_payload(request, shared_adapter_manager: _SharedAdapterMana
|
|
|
207
226
|
except Exception as e:
|
|
208
227
|
log.warning(f"⚠️ [Warning] Failed to sync ref cache from inspect_ui: {e}")
|
|
209
228
|
|
|
210
|
-
|
|
229
|
+
# Annotate in place: ref labels are only meaningful where the compressor
|
|
230
|
+
# emits ref + bbox + clickable (web). Mobile trees carry none of those, so
|
|
231
|
+
# annotate_screenshot draws nothing and just re-encodes the image — same
|
|
232
|
+
# picture, no labels. Either way exactly ONE image ships.
|
|
211
233
|
if screenshot_base64 and ui_tree.get("ui_elements"):
|
|
212
234
|
try:
|
|
213
235
|
from utils.screenshot_annotator import annotate_screenshot
|
|
214
236
|
raw_bytes = base64.b64decode(screenshot_base64)
|
|
215
237
|
annotated_bytes = annotate_screenshot(raw_bytes, ui_tree["ui_elements"])
|
|
216
|
-
|
|
238
|
+
screenshot_base64 = base64.b64encode(annotated_bytes).decode("utf-8")
|
|
217
239
|
except Exception as e:
|
|
218
240
|
log.warning(f"⚠️ [Warning] Annotated screenshot generation failed: {e}")
|
|
219
241
|
|
|
220
|
-
page_url = current_url(adapter, request.platform)
|
|
221
|
-
|
|
222
242
|
return {
|
|
223
243
|
"ok": True,
|
|
224
244
|
"operation": "inspect_ui",
|
|
225
245
|
"exit_code": 0,
|
|
226
246
|
"platform": request.platform,
|
|
227
247
|
"env": request.env,
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
248
|
+
**build_observation(
|
|
249
|
+
ui_tree=ui_tree,
|
|
250
|
+
current_url=current_url(adapter, request.platform),
|
|
251
|
+
screenshot_base64=screenshot_base64 or "",
|
|
252
|
+
memory=_lookup_task_memory(request.platform, request.task),
|
|
253
|
+
candidates=(
|
|
254
|
+
rank_candidates(request.intent, ui_tree.get("ui_elements") or [])
|
|
255
|
+
if str(request.intent).strip()
|
|
256
|
+
else []
|
|
257
|
+
),
|
|
258
|
+
),
|
|
234
259
|
}
|
|
235
260
|
except Exception as e:
|
|
236
261
|
return {
|
|
@@ -241,6 +266,8 @@ def build_inspect_ui_payload(request, shared_adapter_manager: _SharedAdapterMana
|
|
|
241
266
|
"env": request.env,
|
|
242
267
|
"error": str(e),
|
|
243
268
|
"current_url": "",
|
|
269
|
+
"memory": {},
|
|
270
|
+
"candidates": [],
|
|
244
271
|
}
|
|
245
272
|
finally:
|
|
246
273
|
if owns_adapter and adapter:
|
|
@@ -24,6 +24,17 @@ def _slugify(value: str) -> str:
|
|
|
24
24
|
return text.lower() or "memory"
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
# Inline `--action` runs all record this same source_ref: it says "typed on the
|
|
28
|
+
# command line", not "which control". Treating it as an identity made the first
|
|
29
|
+
# inline action on a platform absorb every later one, so a named action could
|
|
30
|
+
# never get its own entry — or be found by its name.
|
|
31
|
+
_NON_IDENTIFYING_SOURCE_REFS = frozenset({"inline://action"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _identifying_source_ref(source_ref: str) -> str:
|
|
35
|
+
return "" if source_ref in _NON_IDENTIFYING_SOURCE_REFS else source_ref
|
|
36
|
+
|
|
37
|
+
|
|
27
38
|
def _build_memory_id(
|
|
28
39
|
platform: str,
|
|
29
40
|
control_kind: str,
|
|
@@ -240,7 +251,7 @@ class CaseMemoryStore:
|
|
|
240
251
|
normalized_platform = _normalize_text(platform).lower()
|
|
241
252
|
normalized_kind = _normalize_text(control_kind).lower()
|
|
242
253
|
normalized_label = _normalize_text(control_label)
|
|
243
|
-
normalized_source_ref = _normalize_text(source_ref)
|
|
254
|
+
normalized_source_ref = _identifying_source_ref(_normalize_text(source_ref))
|
|
244
255
|
document = self.load_document()
|
|
245
256
|
|
|
246
257
|
for entry in document.entries:
|
|
@@ -277,10 +288,11 @@ class CaseMemoryStore:
|
|
|
277
288
|
|
|
278
289
|
document = self.load_document()
|
|
279
290
|
existing_entry = None
|
|
291
|
+
identifying_source_ref = _identifying_source_ref(source_ref)
|
|
280
292
|
for entry in document.entries:
|
|
281
293
|
if entry.platform != platform or entry.control_kind != control_kind:
|
|
282
294
|
continue
|
|
283
|
-
if
|
|
295
|
+
if identifying_source_ref and entry.source_ref == identifying_source_ref:
|
|
284
296
|
existing_entry = entry
|
|
285
297
|
break
|
|
286
298
|
if entry.control_label == control_label:
|
|
@@ -17,9 +17,12 @@ from common.error_codes import lookup
|
|
|
17
17
|
CANDIDATE_THRESHOLD = 0.55 # difflib ratio floor; below this is not a candidate
|
|
18
18
|
MAX_CANDIDATES = 3
|
|
19
19
|
|
|
20
|
-
# Element text fields to match against
|
|
21
|
-
#
|
|
22
|
-
|
|
20
|
+
# Element text fields to match against. Every compressor's label-bearing keys
|
|
21
|
+
# must appear here or that platform silently yields zero candidates:
|
|
22
|
+
# utils_xml.py (Android) writes text/desc, utils_web.py writes text/desc/name,
|
|
23
|
+
# utils_ios.py writes ONLY label/name — and suppresses `name` when it equals
|
|
24
|
+
# `label`, so without "label" the typical iOS control matched nothing at all.
|
|
25
|
+
_MATCH_FIELDS = ("text", "desc", "label", "name")
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
@dataclass
|
|
@@ -62,12 +65,27 @@ def _best_field(el: dict, target: str) -> tuple[float, str, str]:
|
|
|
62
65
|
|
|
63
66
|
|
|
64
67
|
def _suggested_locator(el: dict, matched_text: str, matched_field: str) -> dict:
|
|
65
|
-
"""Locator priority: ref (web) > text > description.
|
|
68
|
+
"""Locator priority: ref (web) > resourceId > text > description.
|
|
69
|
+
|
|
70
|
+
Mirrors the repo's locator law (CLAUDE.md, and the brain prompt in
|
|
71
|
+
common/ai.py). Mobile compressors (utils/utils_xml.py, utils/utils_ios.py)
|
|
72
|
+
emit `id` but never `ref`, so without the resourceId branch every mobile
|
|
73
|
+
candidate degraded to a text locator — weaker than what the page offered.
|
|
74
|
+
"""
|
|
66
75
|
ref = str(el.get("ref", "")).strip()
|
|
67
76
|
if ref:
|
|
68
77
|
return {"type": "ref", "value": ref}
|
|
78
|
+
resource_id = str(el.get("id", "")).strip()
|
|
79
|
+
if resource_id:
|
|
80
|
+
return {"type": "resourceId", "value": resource_id}
|
|
69
81
|
if matched_field == "text":
|
|
70
82
|
return {"type": "text", "value": matched_text}
|
|
83
|
+
# iOS is the only platform that reaches here on a `name` match (web always
|
|
84
|
+
# carries a ref, Android an id). executor.py's ios_key_map sends
|
|
85
|
+
# resourceId->name and description->label, so a name match MUST say
|
|
86
|
+
# resourceId or the agent would query the wrong attribute.
|
|
87
|
+
if matched_field == "name":
|
|
88
|
+
return {"type": "resourceId", "value": matched_text}
|
|
71
89
|
return {"type": "description", "value": matched_text}
|
|
72
90
|
|
|
73
91
|
|
|
@@ -91,6 +109,24 @@ def _rank_candidates(locator_value: str, ui_elements: list[dict]) -> list[Candid
|
|
|
91
109
|
return scored[:MAX_CANDIDATES]
|
|
92
110
|
|
|
93
111
|
|
|
112
|
+
def rank_candidates(phrase: str, ui_elements: list[dict]) -> list[dict]:
|
|
113
|
+
"""Rank elements against a plain-language phrase; JSON-ready dicts.
|
|
114
|
+
|
|
115
|
+
The forward half of did-you-mean: instead of explaining a locator that just
|
|
116
|
+
failed, answer "which elements could the agent mean by this sentence?" Same
|
|
117
|
+
ranking, same 0.55 floor, same at-most-3, same rather-nothing-than-a-guess.
|
|
118
|
+
|
|
119
|
+
ponytail: difflib is LITERAL similarity, not semantic — "登录" vs "Sign in"
|
|
120
|
+
scores ~0, so this returns [] on mixed-language pages. Upgrade path if that
|
|
121
|
+
matters: embed phrase + labels and rank by cosine, reusing the embedding
|
|
122
|
+
infrastructure already behind the L2 semantic cache (common/cache/).
|
|
123
|
+
"""
|
|
124
|
+
return [
|
|
125
|
+
{"text": c.text, "score": c.score, "locator": c.locator}
|
|
126
|
+
for c in _rank_candidates(phrase, ui_elements)
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
94
130
|
def _recommend(error_code: str, fix: str, candidates: list[Candidate]) -> dict:
|
|
95
131
|
if candidates:
|
|
96
132
|
top = candidates[0]
|
|
@@ -167,6 +167,14 @@ def _build_inspect_ui_tool_schema() -> dict:
|
|
|
167
167
|
"type": "boolean",
|
|
168
168
|
"description": "是否同时返回截图 base64",
|
|
169
169
|
},
|
|
170
|
+
"task": {
|
|
171
|
+
"type": "string",
|
|
172
|
+
"description": "可选的任务标签(如 \"点击登录\")。提供时,返回该任务上次成功的定位器作为提示(memory 字段);不提供则 memory 为空对象。",
|
|
173
|
+
},
|
|
174
|
+
"intent": {
|
|
175
|
+
"type": "string",
|
|
176
|
+
"description": "可选的人话短语(如 \"登录按钮\")。提供时,返回按字面相似度排序的候选元素及可直接使用的定位器(candidates 字段);匹配不到则为空数组,不会瞎猜。",
|
|
177
|
+
},
|
|
170
178
|
},
|
|
171
179
|
"additionalProperties": False,
|
|
172
180
|
}
|
|
@@ -280,15 +288,25 @@ def _build_initialize_result(protocol_version: str) -> dict:
|
|
|
280
288
|
|
|
281
289
|
|
|
282
290
|
def _build_tool_result(payload: dict) -> dict:
|
|
291
|
+
# structuredContent IS the payload — the MCP client reads it directly. The
|
|
292
|
+
# content array must be present and non-empty per spec, so it carries a SHORT
|
|
293
|
+
# summary rather than a second json.dumps of the same dict. Duplicating a
|
|
294
|
+
# payload that holds a UI tree plus a screenshot was the largest avoidable
|
|
295
|
+
# cost on the wire.
|
|
296
|
+
ok = bool(payload.get("ok", False))
|
|
297
|
+
operation = payload.get("operation", "unknown")
|
|
298
|
+
if ok:
|
|
299
|
+
summary = f"{operation}: ok"
|
|
300
|
+
count = payload.get("element_count")
|
|
301
|
+
if count is not None:
|
|
302
|
+
summary += f" ({count} elements)"
|
|
303
|
+
else:
|
|
304
|
+
detail = str(payload.get("error", "") or payload.get("result", "") or "failed")
|
|
305
|
+
summary = f"{operation}: failed — {detail[:120]}"
|
|
283
306
|
return {
|
|
284
|
-
"content": [
|
|
285
|
-
{
|
|
286
|
-
"type": "text",
|
|
287
|
-
"text": json.dumps(payload, ensure_ascii=False, indent=2),
|
|
288
|
-
}
|
|
289
|
-
],
|
|
307
|
+
"content": [{"type": "text", "text": summary}],
|
|
290
308
|
"structuredContent": payload,
|
|
291
|
-
"isError": not
|
|
309
|
+
"isError": not ok,
|
|
292
310
|
}
|
|
293
311
|
|
|
294
312
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The ONE agent-facing observation envelope.
|
|
2
|
+
|
|
3
|
+
Every payload the external agent reads — inspect_ui, --action --json success and
|
|
4
|
+
engine_error, the MCP observation stash — composes this function. Adding a field
|
|
5
|
+
means editing THIS file and nothing else; that is the whole point. Two
|
|
6
|
+
constructors used to hand-assemble their own keys and drifted (the inspect
|
|
7
|
+
payload shipped the tree twice and two screenshots; case-memory reached the
|
|
8
|
+
agent only AFTER it had already acted).
|
|
9
|
+
|
|
10
|
+
Pure: no I/O, no device, no config reads — same contract as
|
|
11
|
+
cli/modes/action.py::build_success_payload.
|
|
12
|
+
|
|
13
|
+
Invariants:
|
|
14
|
+
1. Additive only. Never shrink or truncate ui_tree — a trimmed tree makes the
|
|
15
|
+
agent hit [E030] Ref not found and re-inspect, which costs MORE tokens.
|
|
16
|
+
2. Honest empties. Absent data is {} / [] / "", never a guess (same rule as
|
|
17
|
+
common/failure_diagnosis.py's below-threshold behavior).
|
|
18
|
+
3. element_count is DERIVED here, never accepted from a caller, so it can
|
|
19
|
+
never disagree with the tree it describes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_observation(
|
|
24
|
+
*,
|
|
25
|
+
ui_tree: dict,
|
|
26
|
+
current_url: str = "",
|
|
27
|
+
screenshot_base64: str = "",
|
|
28
|
+
memory: dict | None = None,
|
|
29
|
+
candidates: list[dict] | None = None,
|
|
30
|
+
) -> dict:
|
|
31
|
+
elements = (ui_tree or {}).get("ui_elements") or []
|
|
32
|
+
return {
|
|
33
|
+
"ui_tree": ui_tree or {},
|
|
34
|
+
"element_count": len(elements),
|
|
35
|
+
"current_url": current_url,
|
|
36
|
+
"screenshot_base64": screenshot_base64,
|
|
37
|
+
# A HINT, never an instruction: what the last run of this task DID —
|
|
38
|
+
# its locator and its outcome, success or failure (case_memory records
|
|
39
|
+
# both, so `last_status`/`success_count` are what say which). The agent
|
|
40
|
+
# reads it alongside the live tree and decides whether to trust it. We
|
|
41
|
+
# deliberately do NOT replay it (that is Midscene's XPath-cache route,
|
|
42
|
+
# which silently clicks the wrong thing after a redesign).
|
|
43
|
+
"memory": memory or {},
|
|
44
|
+
# Elements matching the agent's plain-language phrase, best first. Empty
|
|
45
|
+
# is a valid answer — below the similarity floor we return nothing rather
|
|
46
|
+
# than a confident wrong guess.
|
|
47
|
+
"candidates": candidates or [],
|
|
48
|
+
}
|
|
@@ -65,6 +65,12 @@ class ToolRequest(BaseModel):
|
|
|
65
65
|
resume_run_id: str = ""
|
|
66
66
|
run_id: str = ""
|
|
67
67
|
goal: str = ""
|
|
68
|
+
# Optional task label for inspect_ui: lets the payload carry the case-memory
|
|
69
|
+
# hint for THIS task at decision time. Omitted → no lookup, no hint.
|
|
70
|
+
task: str = ""
|
|
71
|
+
# Optional plain-language phrase for inspect_ui: returns ranked candidate
|
|
72
|
+
# elements (the `candidates` field). Omitted → no ranking, empty list.
|
|
73
|
+
intent: str = ""
|
|
68
74
|
query: str = ""
|
|
69
75
|
source_ref: str = ""
|
|
70
76
|
control_kind: str = ""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: screenforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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
|
|
@@ -120,6 +120,8 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
|
|
|
120
120
|
- **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
|
|
121
121
|
- **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
|
|
122
122
|
- **Execution-replay review report**: Run any test with `REVIEW_RECORD=1` and get a self-contained, shareable offline `report.html` — a drag-timeline of each step's screenshot + the test source line that triggered it + the DOM tree, plus a `video.gif` filmstrip. Default off, zero overhead. (Web verified; mobile registry/recording seam in place but unverified.)
|
|
123
|
+
- **Case memory hints on `inspect_ui`**: pass an optional `task` label and the payload's `memory` field carries what the last run of that task did — locator, counts, and outcome — a hint the Agent weighs against the live tree, seen before it acts, never auto-replayed. It records failures too, so check `last_status`/`success_count` before trusting a hint. The label must match the one the earlier run recorded (`--action-name`, else `"{action}:{locator_value}"`); `load_case_memory` lists them. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
|
|
124
|
+
- **Plain-language element lookup on `inspect_ui`**: pass an optional `intent` phrase (e.g. `"登录按钮"`) and the payload's `candidates` field returns up to 3 ranked elements with ready-to-use locators — additive to the full `ui_tree`, never a replacement. Matching is literal string similarity, not semantic, so phrase `intent` in the same language as the UI. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
|
|
123
125
|
|
|
124
126
|
## Agent Integration (Claude Code / Cursor / Codex)
|
|
125
127
|
|
|
@@ -33,6 +33,7 @@ common/failure_diagnosis.py
|
|
|
33
33
|
common/history_manager.py
|
|
34
34
|
common/logs.py
|
|
35
35
|
common/mcp_server.py
|
|
36
|
+
common/observation.py
|
|
36
37
|
common/preflight.py
|
|
37
38
|
common/progress.py
|
|
38
39
|
common/run_reporter.py
|
|
@@ -69,6 +70,7 @@ tests/test_android_smoke_live.py
|
|
|
69
70
|
tests/test_cache_hash.py
|
|
70
71
|
tests/test_cache_manager.py
|
|
71
72
|
tests/test_capabilities.py
|
|
73
|
+
tests/test_case_memory_identity.py
|
|
72
74
|
tests/test_cli_action_json.py
|
|
73
75
|
tests/test_codegen_quality.py
|
|
74
76
|
tests/test_dispatch.py
|
|
@@ -77,13 +79,22 @@ tests/test_dom_capture.py
|
|
|
77
79
|
tests/test_error_codes.py
|
|
78
80
|
tests/test_executor.py
|
|
79
81
|
tests/test_failure_diagnosis.py
|
|
82
|
+
tests/test_failure_diagnosis_ios.py
|
|
83
|
+
tests/test_failure_diagnosis_mobile.py
|
|
84
|
+
tests/test_forward_candidates.py
|
|
85
|
+
tests/test_inspect_ui_candidates.py
|
|
86
|
+
tests/test_inspect_ui_envelope.py
|
|
87
|
+
tests/test_inspect_ui_memory.py
|
|
80
88
|
tests/test_interaction_actions.py
|
|
81
89
|
tests/test_ios_smoke_live.py
|
|
82
90
|
tests/test_mcp_execute_observation.py
|
|
83
91
|
tests/test_mcp_execute_parity_live.py
|
|
84
92
|
tests/test_mcp_ref_cache.py
|
|
93
|
+
tests/test_mcp_tool_result.py
|
|
85
94
|
tests/test_ml_optional.py
|
|
86
95
|
tests/test_mode_error_code_threading.py
|
|
96
|
+
tests/test_observation.py
|
|
97
|
+
tests/test_observation_action_parity.py
|
|
87
98
|
tests/test_parser.py
|
|
88
99
|
tests/test_playground_app.py
|
|
89
100
|
tests/test_playground_sink.py
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Inline actions must not all collapse into one case-memory entry.
|
|
2
|
+
|
|
3
|
+
Every inline `--action` run writes the same sentinel `source_ref`
|
|
4
|
+
("inline://action"), and both the upsert and the lookup matched `source_ref`
|
|
5
|
+
BEFORE `control_label`. So the first inline action recorded on a platform
|
|
6
|
+
absorbed every later one: `--action-name "点击登录"` bumped the counter on the
|
|
7
|
+
pre-existing `swipe:up` entry instead of creating its own, and `task="点击登录"`
|
|
8
|
+
could never hit. The sentinel is provenance for reports, never an identity.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from common.case_memory import CaseMemoryStore
|
|
15
|
+
|
|
16
|
+
_SENTINEL = "inline://action"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _store() -> CaseMemoryStore:
|
|
20
|
+
return CaseMemoryStore(file_path=Path(tempfile.mkdtemp()) / "case_memory.json")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run(store: CaseMemoryStore, label: str, source_ref: str = _SENTINEL) -> None:
|
|
24
|
+
store.upsert_from_run(
|
|
25
|
+
summary={
|
|
26
|
+
"execution_mode": "run",
|
|
27
|
+
"platform": "android",
|
|
28
|
+
"status": "success",
|
|
29
|
+
"run_id": "r1",
|
|
30
|
+
"finished_at": "2026-07-27T20:00:00",
|
|
31
|
+
"control_summary": {
|
|
32
|
+
"control_kind": "action",
|
|
33
|
+
"control_label": label,
|
|
34
|
+
"source_ref": source_ref,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
step_records=[],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_distinct_action_labels_get_distinct_entries():
|
|
42
|
+
store = _store()
|
|
43
|
+
_run(store, "swipe:up")
|
|
44
|
+
_run(store, "点击登录")
|
|
45
|
+
labels = sorted(e.control_label for e in store.load_document().entries)
|
|
46
|
+
assert labels == ["swipe:up", "点击登录"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_repeating_the_same_label_still_updates_one_entry():
|
|
50
|
+
store = _store()
|
|
51
|
+
_run(store, "swipe:up")
|
|
52
|
+
_run(store, "swipe:up")
|
|
53
|
+
entries = store.load_document().entries
|
|
54
|
+
assert len(entries) == 1
|
|
55
|
+
assert entries[0].success_count == 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_named_action_is_findable_by_its_label():
|
|
59
|
+
store = _store()
|
|
60
|
+
_run(store, "swipe:up")
|
|
61
|
+
_run(store, "点击登录")
|
|
62
|
+
hit = store.find_entry(
|
|
63
|
+
platform="android", control_kind="action", control_label="点击登录"
|
|
64
|
+
)
|
|
65
|
+
assert hit is not None
|
|
66
|
+
assert hit["control_label"] == "点击登录"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_sentinel_source_ref_never_matches_a_different_label():
|
|
70
|
+
# The bug's read half: a lookup carrying the sentinel returned whichever
|
|
71
|
+
# inline entry came first, regardless of the label asked for.
|
|
72
|
+
store = _store()
|
|
73
|
+
_run(store, "swipe:up")
|
|
74
|
+
assert (
|
|
75
|
+
store.find_entry(
|
|
76
|
+
platform="android",
|
|
77
|
+
control_kind="action",
|
|
78
|
+
control_label="点击登录",
|
|
79
|
+
source_ref=_SENTINEL,
|
|
80
|
+
)
|
|
81
|
+
is None
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_real_source_ref_still_identifies_across_a_rename():
|
|
86
|
+
# Workflows carry a real path: renaming the workflow must not orphan its
|
|
87
|
+
# history. This is the behavior the sentinel was free-riding on.
|
|
88
|
+
store = _store()
|
|
89
|
+
_run(store, "old_name", source_ref="/tmp/flow.yaml")
|
|
90
|
+
_run(store, "new_name", source_ref="/tmp/flow.yaml")
|
|
91
|
+
entries = store.load_document().entries
|
|
92
|
+
assert len(entries) == 1
|
|
93
|
+
assert entries[0].success_count == 2
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""iOS elements carry `label`/`name` and nothing else — match on them.
|
|
2
|
+
|
|
3
|
+
utils/utils_ios.py's compress_ios_source writes only type/label/name/value/
|
|
4
|
+
accessible/disabled: no `text`, no `desc`, no `id`, no `ref`. While _MATCH_FIELDS
|
|
5
|
+
was ("text", "desc", "name") the whole did-you-mean feature was dead on iOS —
|
|
6
|
+
and dead in the worst way, because the compressor drops `name` when it equals
|
|
7
|
+
`label`, so the ordinary control matched nothing while an odd one matched by its
|
|
8
|
+
internal identifier and got handed back as a `description` locator. That is the
|
|
9
|
+
wrong attribute: executor.py's ios_key_map sends description->label.
|
|
10
|
+
|
|
11
|
+
Threshold note: CANDIDATE_THRESHOLD = 0.55, and probes here are exact or near
|
|
12
|
+
matches so they clear it comfortably.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from common.failure_diagnosis import diagnose, rank_candidates
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_label_only_element_is_a_candidate():
|
|
19
|
+
"""The common iOS shape: label present, name suppressed as a duplicate."""
|
|
20
|
+
diag = diagnose(
|
|
21
|
+
error_code="E030",
|
|
22
|
+
locator_value="登录",
|
|
23
|
+
ui_elements=[{"type": "Button", "label": "登录"}],
|
|
24
|
+
)
|
|
25
|
+
assert diag.candidates
|
|
26
|
+
assert diag.candidates[0].locator == {"type": "description", "value": "登录"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_label_match_wins_over_name_and_maps_to_label_lookup():
|
|
30
|
+
"""A label match must not be reported as resourceId — that would query `name`."""
|
|
31
|
+
diag = diagnose(
|
|
32
|
+
error_code="E030",
|
|
33
|
+
locator_value="登录",
|
|
34
|
+
ui_elements=[{"type": "Button", "label": "登录", "name": "loginBtn"}],
|
|
35
|
+
)
|
|
36
|
+
assert diag.candidates[0].locator == {"type": "description", "value": "登录"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_name_match_maps_to_resource_id_not_description():
|
|
40
|
+
"""`name` is iOS's identifier: resourceId->name in executor's ios_key_map."""
|
|
41
|
+
diag = diagnose(
|
|
42
|
+
error_code="E030",
|
|
43
|
+
locator_value="loginBtn",
|
|
44
|
+
ui_elements=[{"type": "Button", "label": "Sign in", "name": "loginBtn"}],
|
|
45
|
+
)
|
|
46
|
+
assert diag.candidates[0].locator == {"type": "resourceId", "value": "loginBtn"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_forward_lookup_also_sees_ios_labels():
|
|
50
|
+
"""rank_candidates() shares the matcher, so `intent` works on iOS too."""
|
|
51
|
+
hits = rank_candidates("登录", [{"type": "Button", "label": "登录"}])
|
|
52
|
+
assert [h["locator"] for h in hits] == [{"type": "description", "value": "登录"}]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_web_name_attribute_still_yields_ref():
|
|
56
|
+
"""Adding `label` must not disturb web: ref outranks every text field."""
|
|
57
|
+
diag = diagnose(
|
|
58
|
+
error_code="E030",
|
|
59
|
+
locator_value="username",
|
|
60
|
+
ui_elements=[{"class": "input", "name": "username", "ref": "@3", "id": "user"}],
|
|
61
|
+
)
|
|
62
|
+
assert diag.candidates[0].locator == {"type": "ref", "value": "@3"}
|