chore: archive tools/ui_inspector snapshot before migration removal.
Keep the last in-template copy on main so history can restore it; product continues in jiangchang-ui-recorder. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0
tools/ui_inspector/tests/__init__.py
Normal file
0
tools/ui_inspector/tests/__init__.py
Normal file
1
tools/ui_inspector/tests/contract/__init__.py
Normal file
1
tools/ui_inspector/tests/contract/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
207
tools/ui_inspector/tests/contract/test_capture_contract.py
Normal file
207
tools/ui_inspector/tests/contract/test_capture_contract.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from pathlib import Path
|
||||
|
||||
from flow_session import FlowSession
|
||||
from recorder.base import RawUserEvent
|
||||
from diagnostics.capture_validator import validate_capture
|
||||
from diagnostics.replay_preflight import build_replay_preflight
|
||||
|
||||
|
||||
def test_session_always_creates_raw_event_journal(tmp_path: Path) -> None:
|
||||
session = FlowSession(tmp_path, flow_name="contract", surface="browser", dump_fn=None)
|
||||
session.manager.start()
|
||||
session.manager.status = "recording"
|
||||
event = RawUserEvent(kind="click", surface="browser", target={"role": "button", "name": "Go"})
|
||||
session.journal.append(event, channel="semantic")
|
||||
assert (tmp_path / "journal" / "raw_events.jsonl").exists()
|
||||
|
||||
|
||||
def test_validator_flags_oversized_click_target(tmp_path: Path) -> None:
|
||||
import json
|
||||
actions = tmp_path / "actions"
|
||||
actions.mkdir()
|
||||
(tmp_path / "elements").mkdir()
|
||||
(tmp_path / "observations").mkdir()
|
||||
(tmp_path / "journal").mkdir()
|
||||
(tmp_path / "journal" / "raw_events.jsonl").write_text("{}\n", encoding="utf-8")
|
||||
(actions / "a.json").write_text(
|
||||
json.dumps({
|
||||
"id": "a", "sequence": 1, "action_type": "click",
|
||||
"target_snapshot": {"rect": {"width": 1200, "height": 5000}},
|
||||
"evidence": {"eventual_observation_id": "obs"},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = validate_capture(tmp_path)
|
||||
assert any(issue["code"] == "CLICK_TARGET_OVERSIZED" for issue in result["issues"])
|
||||
|
||||
|
||||
def test_validator_flags_missing_desktop_input_and_generic_targets(tmp_path: Path) -> None:
|
||||
import json
|
||||
|
||||
(tmp_path / "actions").mkdir()
|
||||
(tmp_path / "elements").mkdir()
|
||||
(tmp_path / "observations").mkdir()
|
||||
(tmp_path / "journal").mkdir()
|
||||
journal = {
|
||||
"event": {
|
||||
"kind": "typing_activity", "surface": "windows",
|
||||
"target": {"name": "searchedit", "control_type": "TreeControl"},
|
||||
}
|
||||
}
|
||||
(tmp_path / "journal" / "raw_events.jsonl").write_text(
|
||||
json.dumps(journal) + "\n", encoding="utf-8"
|
||||
)
|
||||
rows = [
|
||||
{
|
||||
"id": "a1", "sequence": 1, "surface": "windows", "action_type": "click",
|
||||
"target_snapshot": {
|
||||
"name": "search_result", "control_type": "TreeControl",
|
||||
"bounds": {"width": 600, "height": 500},
|
||||
},
|
||||
"evidence": {"eventual_observation_id": "obs"},
|
||||
},
|
||||
{
|
||||
"id": "a2", "sequence": 2, "surface": "windows", "action_type": "press_key",
|
||||
"target_snapshot": {
|
||||
"name": "WeCom", "control_type": "WindowControl",
|
||||
"bounds": {"width": 1440, "height": 900},
|
||||
},
|
||||
"input": {"key": "Enter"},
|
||||
"evidence": {"eventual_observation_id": "obs"},
|
||||
},
|
||||
]
|
||||
for row in rows:
|
||||
(tmp_path / "actions" / f"{row['id']}.json").write_text(
|
||||
json.dumps(row), encoding="utf-8"
|
||||
)
|
||||
codes = {issue["code"] for issue in validate_capture(tmp_path)["issues"]}
|
||||
assert "CLICK_TARGET_GENERIC_CONTAINER" in codes
|
||||
assert "KEY_TARGET_ROOT_WINDOW" in codes
|
||||
assert "TEXT_INPUT_TRANSACTION_MISSING" in codes
|
||||
replay = build_replay_preflight(tmp_path)
|
||||
assert replay["ready"] is False
|
||||
assert replay["quality_gate"]["passed"] is False
|
||||
assert replay["summary"]["quality_blockers"] >= 1
|
||||
|
||||
|
||||
def test_validator_flags_missing_production_locator_and_raw_link(tmp_path: Path) -> None:
|
||||
import json
|
||||
|
||||
actions = tmp_path / "actions"
|
||||
elements = tmp_path / "elements"
|
||||
actions.mkdir()
|
||||
elements.mkdir()
|
||||
(tmp_path / "observations").mkdir()
|
||||
(tmp_path / "journal").mkdir()
|
||||
(tmp_path / "journal" / "raw_events.jsonl").write_text("{}\n", encoding="utf-8")
|
||||
(elements / "el.json").write_text(
|
||||
json.dumps({"id": "el", "primary_locator": None}), encoding="utf-8"
|
||||
)
|
||||
(actions / "a.json").write_text(
|
||||
json.dumps({
|
||||
"id": "a", "sequence": 1, "surface": "browser",
|
||||
"action_type": "click", "target_element_id": "el",
|
||||
"target_snapshot": {"role": "link", "rect": {"width": 20, "height": 20}},
|
||||
"raw_event": {}, "evidence": {"eventual_observation_id": "obs"},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = validate_capture(tmp_path)
|
||||
codes = {issue["code"] for issue in result["issues"]}
|
||||
assert "ACTION_PRODUCTION_LOCATOR_MISSING" in codes
|
||||
assert "ACTION_RAW_EVENT_LINK_MISSING" in codes
|
||||
|
||||
|
||||
def test_validator_flags_graphic_leaf_low_stability_and_split_fill(tmp_path: Path) -> None:
|
||||
import json
|
||||
|
||||
actions = tmp_path / "actions"
|
||||
elements = tmp_path / "elements"
|
||||
actions.mkdir()
|
||||
elements.mkdir()
|
||||
(tmp_path / "observations").mkdir()
|
||||
(tmp_path / "journal").mkdir()
|
||||
(tmp_path / "journal" / "raw_events.jsonl").write_text("{}\n", encoding="utf-8")
|
||||
locator = {
|
||||
"strategy": "css", "expression": "css:.hash", "actual_count": 1,
|
||||
"unique": True, "production_eligible": True, "stability_score": 0.32,
|
||||
"validation": "live",
|
||||
}
|
||||
(elements / "el.json").write_text(
|
||||
json.dumps({"id": "el", "primary_locator": locator}), encoding="utf-8"
|
||||
)
|
||||
rows = [
|
||||
{
|
||||
"id": "a1", "sequence": 1, "surface": "browser", "action_type": "fill",
|
||||
"target_element_id": "el", "target_snapshot": {"tag": "input"},
|
||||
"input": {"text": "a"},
|
||||
"raw_event": {"meta": {"event_id": "doc:1", "source_ts_ms": 1000}},
|
||||
"evidence": {"eventual_observation_id": "obs", "locator_unique": True},
|
||||
},
|
||||
{
|
||||
"id": "a2", "sequence": 2, "surface": "browser", "action_type": "fill",
|
||||
"target_element_id": "el", "target_snapshot": {"tag": "input"},
|
||||
"input": {"text": "ai"},
|
||||
"raw_event": {"meta": {"event_id": "doc:2", "source_ts_ms": 1100}},
|
||||
"evidence": {"eventual_observation_id": "obs", "locator_unique": True},
|
||||
},
|
||||
{
|
||||
"id": "a3", "sequence": 3, "surface": "browser", "action_type": "click",
|
||||
"target_element_id": "el", "target_snapshot": {"tag": "path"},
|
||||
"raw_event": {"meta": {"event_id": "doc:3"}},
|
||||
"evidence": {"eventual_observation_id": "obs", "locator_unique": True},
|
||||
},
|
||||
]
|
||||
for row in rows:
|
||||
(actions / f"{row['id']}.json").write_text(json.dumps(row), encoding="utf-8")
|
||||
codes = {issue["code"] for issue in validate_capture(tmp_path)["issues"]}
|
||||
assert "DUPLICATE_FILL_TRANSACTION" in codes
|
||||
assert "GRAPHIC_LEAF_TARGET" in codes
|
||||
assert "LOW_STABILITY_LOCATOR" in codes
|
||||
|
||||
|
||||
def test_replay_preflight_is_honest_about_event_time_validation(tmp_path: Path) -> None:
|
||||
import json
|
||||
|
||||
(tmp_path / "actions").mkdir()
|
||||
(tmp_path / "elements").mkdir()
|
||||
(tmp_path / "actions" / "a.json").write_text(json.dumps({
|
||||
"id": "a", "sequence": 1, "action_type": "click",
|
||||
"target_element_id": "el", "raw_event_ids": ["doc:1"],
|
||||
"raw_event": {"meta": {"page_id": "page", "document_id": "doc"}},
|
||||
}), encoding="utf-8")
|
||||
(tmp_path / "elements" / "el.json").write_text(json.dumps({
|
||||
"id": "el", "logical_name": "go", "primary_locator": {
|
||||
"strategy": "role", "expression": "role:button,Go",
|
||||
"actual_count": 1, "unique": True, "production_eligible": True,
|
||||
"stability_score": 0.95, "validation": "live",
|
||||
},
|
||||
}), encoding="utf-8")
|
||||
report = build_replay_preflight(tmp_path)
|
||||
assert report["mode"] == "static_non_mutating"
|
||||
assert report["summary"]["pass"] == 1
|
||||
assert report["actions"][0]["verification"] == "event_time_unique"
|
||||
|
||||
|
||||
def test_replay_preflight_rejects_unique_root_window_click(tmp_path: Path) -> None:
|
||||
import json
|
||||
|
||||
(tmp_path / "actions").mkdir()
|
||||
(tmp_path / "elements").mkdir()
|
||||
(tmp_path / "actions" / "a.json").write_text(json.dumps({
|
||||
"id": "a", "sequence": 1, "surface": "windows", "action_type": "click",
|
||||
"target_element_id": "root", "target_snapshot": {
|
||||
"control_type": "WindowControl", "name": "WeCom",
|
||||
"bounds": {"width": 1440, "height": 900},
|
||||
},
|
||||
}), encoding="utf-8")
|
||||
(tmp_path / "elements" / "root.json").write_text(json.dumps({
|
||||
"id": "root", "logical_name": "WeCom", "primary_locator": {
|
||||
"strategy": "window_name_type", "expression": "window:WeCom",
|
||||
"actual_count": 1, "unique": True, "production_eligible": True,
|
||||
"stability_score": 0.9, "validation": "snapshot_only",
|
||||
},
|
||||
}), encoding="utf-8")
|
||||
report = build_replay_preflight(tmp_path)
|
||||
assert report["summary"]["fail"] == 1
|
||||
assert report["actions"][0]["reason"] == "oversized_or_root_window_click_target"
|
||||
215
tools/ui_inspector/tests/test_locators_catalog.py
Normal file
215
tools/ui_inspector/tests/test_locators_catalog.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Locator compiler / catalog / uniqueness tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
_HERE = Path(__file__).resolve().parents[1]
|
||||
if str(_HERE) not in sys.path:
|
||||
sys.path.insert(0, str(_HERE))
|
||||
|
||||
from locators.browser import compile_browser_locators
|
||||
from locators.catalog import ElementCatalogService
|
||||
from locators.validator import choose_production_primary, validate_locator_against_rows
|
||||
from locators.windows import compile_windows_locators
|
||||
from models.locator import LocatorCandidate
|
||||
|
||||
|
||||
class TestBrowserLocators(unittest.TestCase):
|
||||
def test_ordering_prefers_role_and_testid(self) -> None:
|
||||
el = {
|
||||
"role": "button",
|
||||
"name": "Submit",
|
||||
"test_id": "submit-btn",
|
||||
"text": "Submit",
|
||||
"id": "x1",
|
||||
}
|
||||
rows = [
|
||||
{"role": "button", "name": "Submit", "test_id": "submit-btn", "text": "Submit", "id": "x1"},
|
||||
{"role": "button", "name": "Cancel", "test_id": "cancel-btn", "text": "Cancel"},
|
||||
]
|
||||
cands = compile_browser_locators(el, rows=rows)
|
||||
strategies = [c.strategy for c in cands]
|
||||
self.assertIn("role", strategies)
|
||||
self.assertIn("test_id", strategies)
|
||||
primary = choose_production_primary(cands)
|
||||
self.assertIsNotNone(primary)
|
||||
assert primary is not None
|
||||
self.assertFalse(primary.uses_coordinates)
|
||||
|
||||
def test_contenteditable_ignores_dynamic_value_and_uses_role_css(self) -> None:
|
||||
cands = compile_browser_locators(
|
||||
{
|
||||
"role": "combobox",
|
||||
"name": "user typed comment",
|
||||
"text": "user typed comment",
|
||||
"value": "user typed comment",
|
||||
"contenteditable": True,
|
||||
"css": "#comment-input [contenteditable=true]",
|
||||
}
|
||||
)
|
||||
role = next(c for c in cands if c.strategy == "role")
|
||||
css = next(c for c in cands if c.strategy == "css")
|
||||
self.assertEqual(role.properties, {"role": "combobox"})
|
||||
self.assertNotIn("user typed comment", role.expression)
|
||||
self.assertFalse(css.production_eligible)
|
||||
|
||||
def test_live_unique_css_becomes_executable_fallback(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
service = ElementCatalogService(Path(tmp), flow_id="f")
|
||||
el = service.upsert_from_snapshot(
|
||||
{
|
||||
"role": "path",
|
||||
"tag": "path",
|
||||
"css": "#comment-input .submit svg path",
|
||||
"fingerprint": "path|submit",
|
||||
},
|
||||
surface="browser",
|
||||
frame={"url": "https://example.test/video"},
|
||||
live_counts={"css": 1},
|
||||
)
|
||||
self.assertIsNotNone(el.primary_locator)
|
||||
assert el.primary_locator is not None
|
||||
self.assertEqual(el.primary_locator.strategy, "css")
|
||||
self.assertTrue(el.primary_locator.unique)
|
||||
|
||||
def test_link_href_beats_ambiguous_card_css(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
service = ElementCatalogService(Path(tmp), flow_id="f")
|
||||
el = service.upsert_from_snapshot(
|
||||
{
|
||||
"role": "link",
|
||||
"tag": "a",
|
||||
"name": "A result card with dynamic text",
|
||||
"href": "/detail/42",
|
||||
"href_count": 1,
|
||||
"css": "#results > li.card > a.card-link",
|
||||
"css_count": 24,
|
||||
"fingerprint": "a|card",
|
||||
},
|
||||
surface="browser",
|
||||
frame={"url": "https://example.test/search"},
|
||||
live_counts={"href": 1, "css": 24},
|
||||
)
|
||||
self.assertIsNotNone(el.primary_locator)
|
||||
assert el.primary_locator is not None
|
||||
self.assertEqual(el.primary_locator.strategy, "href")
|
||||
self.assertEqual(el.primary_locator.actual_count, 1)
|
||||
self.assertTrue(el.primary_locator.production_eligible)
|
||||
|
||||
def test_scoped_contenteditable_stable_css_can_be_primary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
service = ElementCatalogService(Path(tmp), flow_id="f")
|
||||
el = service.upsert_from_snapshot(
|
||||
{
|
||||
"role": "combobox",
|
||||
"tag": "div",
|
||||
"contenteditable": True,
|
||||
"stable_css": "#comment-form div[contenteditable]",
|
||||
"stable_css_count": 1,
|
||||
"css": "#comment-form > div.hash-a > div.hash-b",
|
||||
"css_count": 1,
|
||||
"fingerprint": "div|editor",
|
||||
},
|
||||
surface="browser",
|
||||
frame={"url": "https://example.test/detail"},
|
||||
live_counts={"stable_css": 1, "css": 1},
|
||||
)
|
||||
self.assertIsNotNone(el.primary_locator)
|
||||
assert el.primary_locator is not None
|
||||
self.assertEqual(el.primary_locator.strategy, "stable_css")
|
||||
self.assertGreater(el.primary_locator.stability_score, 0.7)
|
||||
|
||||
|
||||
class TestWindowsLocators(unittest.TestCase):
|
||||
def test_ordering_and_name_value_split(self) -> None:
|
||||
el = {
|
||||
"automation_id": "searchedit",
|
||||
"name": "searchedit",
|
||||
"value": "张三",
|
||||
"control_type": "EditControl",
|
||||
"parent_chain": [{"name": "search_layout"}],
|
||||
}
|
||||
rows = [
|
||||
{
|
||||
"automation_id": "searchedit",
|
||||
"name": "searchedit",
|
||||
"value": "张三",
|
||||
"control_type": "EditControl",
|
||||
}
|
||||
]
|
||||
cands = compile_windows_locators(
|
||||
el, rows=rows, window_scope={"class_name": "WeWorkWindow", "title": "企业微信"}
|
||||
)
|
||||
strategies = [c.strategy for c in cands]
|
||||
self.assertTrue(strategies[0].startswith("window_automation_id"))
|
||||
self.assertIn("window_name_value_type", strategies)
|
||||
primary = choose_production_primary(cands)
|
||||
self.assertIsNotNone(primary)
|
||||
assert primary is not None
|
||||
self.assertEqual(primary.actual_count, 1)
|
||||
|
||||
def test_coordinates_never_primary(self) -> None:
|
||||
el = {
|
||||
"name": "x",
|
||||
"control_type": "ButtonControl",
|
||||
"bounds": {"left": 1, "top": 2, "right": 3, "bottom": 4},
|
||||
}
|
||||
# Two matches → ambiguous for name+type; coords present
|
||||
rows = [
|
||||
{"name": "x", "control_type": "ButtonControl"},
|
||||
{"name": "x", "control_type": "ButtonControl"},
|
||||
]
|
||||
cands = compile_windows_locators(el, rows=rows)
|
||||
primary = choose_production_primary(cands)
|
||||
self.assertIsNone(primary)
|
||||
self.assertTrue(any(c.uses_coordinates for c in cands))
|
||||
for c in cands:
|
||||
if c.uses_coordinates:
|
||||
self.assertFalse(c.production_eligible)
|
||||
|
||||
|
||||
class TestUniqueness(unittest.TestCase):
|
||||
def test_ambiguous_not_primary(self) -> None:
|
||||
c = LocatorCandidate(
|
||||
strategy="name",
|
||||
properties={"name": "item", "control_type": "ButtonControl"},
|
||||
stability_score=0.8,
|
||||
)
|
||||
rows = [
|
||||
{"name": "item", "control_type": "ButtonControl"},
|
||||
{"name": "item", "control_type": "ButtonControl"},
|
||||
]
|
||||
validate_locator_against_rows(c, rows)
|
||||
self.assertTrue(c.ambiguous)
|
||||
self.assertFalse(c.production_eligible)
|
||||
self.assertIsNone(choose_production_primary([c]))
|
||||
|
||||
|
||||
class TestCatalogMerge(unittest.TestCase):
|
||||
def test_merge_observations(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
svc = ElementCatalogService(Path(tmp), flow_id="f1")
|
||||
el = {
|
||||
"automation_id": "ok",
|
||||
"name": "ok",
|
||||
"control_type": "ButtonControl",
|
||||
"value": "",
|
||||
}
|
||||
rows = [el]
|
||||
a = svc.upsert_from_snapshot(
|
||||
el, surface="windows", window_scope={"class_name": "X"}, rows=rows
|
||||
)
|
||||
b = svc.upsert_from_snapshot(
|
||||
el, surface="windows", window_scope={"class_name": "X"}, rows=rows
|
||||
)
|
||||
self.assertEqual(a.id, b.id)
|
||||
self.assertEqual(b.observation_count, 2)
|
||||
self.assertTrue(svc.catalog_path.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
295
tools/ui_inspector/tests/test_models_fingerprint_diff.py
Normal file
295
tools/ui_inspector/tests/test_models_fingerprint_diff.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Phase 2 unit tests: models, fingerprint, dedupe, state diff, redaction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
_HERE = Path(__file__).resolve().parents[1]
|
||||
if str(_HERE) not in sys.path:
|
||||
sys.path.insert(0, str(_HERE))
|
||||
|
||||
from models.action import ActionFlow, ActionRecord
|
||||
from models.element import ElementRef, element_fingerprint
|
||||
from models.locator import LocatorCandidate
|
||||
from models.serialize import dumps_json, to_dict
|
||||
from models.state import UIState, compute_state_fingerprint
|
||||
from core.policies.element_identity import element_identity_key
|
||||
from redaction import redact_input_payload, redact_text
|
||||
from snapshot_service import SnapshotService
|
||||
from state_diff import diff_states, propose_postconditions
|
||||
|
||||
|
||||
class TestSerialization(unittest.TestCase):
|
||||
def test_state_serialize(self) -> None:
|
||||
st = UIState(
|
||||
surface="browser",
|
||||
driver="playwright",
|
||||
url="https://example.com",
|
||||
page_title="Example",
|
||||
locatable=[{"control_type": "button", "name": "Go", "value": ""}],
|
||||
)
|
||||
st.ensure_fingerprint()
|
||||
raw = dumps_json(st)
|
||||
self.assertIn("openclaw.ui_inspector.state.v1", raw)
|
||||
self.assertIn("https://example.com", raw)
|
||||
self.assertNotIn("\\u", raw) # ensure_ascii=False for CJK; ASCII ok either way
|
||||
d = to_dict(st)
|
||||
self.assertEqual(d["schema"], "openclaw.ui_inspector.state.v1")
|
||||
self.assertTrue(d["fingerprint"])
|
||||
|
||||
def test_action_serialize(self) -> None:
|
||||
act = ActionRecord(
|
||||
action_type="click",
|
||||
surface="windows",
|
||||
before_state_id="st_a",
|
||||
after_state_id="st_b",
|
||||
sequence=1,
|
||||
)
|
||||
d = to_dict(act)
|
||||
self.assertEqual(d["schema"], "openclaw.ui_inspector.action.v1")
|
||||
self.assertEqual(d["capability"], "implemented")
|
||||
flow = ActionFlow(flow_id="f1", flow_name="demo", actions=[act.id])
|
||||
self.assertEqual(to_dict(flow)["schema"], "openclaw.ui_inspector.action_flow.v1")
|
||||
|
||||
def test_element_locator_serialize(self) -> None:
|
||||
loc = LocatorCandidate(
|
||||
strategy="automation_id",
|
||||
properties={"automation_id": "ok", "control_type": "Button"},
|
||||
expression="AutomationId=ok",
|
||||
stability_score=0.9,
|
||||
)
|
||||
loc.mark_counts(1)
|
||||
el = ElementRef(
|
||||
logical_name="ok_button",
|
||||
surface="windows",
|
||||
automation_id="ok",
|
||||
control_type="ButtonControl",
|
||||
locator_candidates=[loc],
|
||||
primary_locator=loc,
|
||||
)
|
||||
el.ensure_fingerprint()
|
||||
d = to_dict(el)
|
||||
self.assertEqual(d["schema"], "openclaw.ui_inspector.element.v1")
|
||||
self.assertTrue(d["primary_locator"]["production_eligible"])
|
||||
|
||||
|
||||
class TestFingerprint(unittest.TestCase):
|
||||
def test_stable_fingerprint(self) -> None:
|
||||
kwargs = dict(
|
||||
surface="browser",
|
||||
url="https://a.test/x",
|
||||
page_title="T",
|
||||
window={"title": "T", "class_name": "Chrome", "process_name": "chrome.exe"},
|
||||
locatable=[
|
||||
{"control_type": "textbox", "name": "q", "value": "hello", "automation_id": "q"}
|
||||
],
|
||||
)
|
||||
a = compute_state_fingerprint(**kwargs)
|
||||
b = compute_state_fingerprint(**kwargs)
|
||||
self.assertEqual(a, b)
|
||||
|
||||
def test_session_fields_ignored(self) -> None:
|
||||
base_loc = [{"control_type": "button", "name": "A", "value": "", "automation_id": "a"}]
|
||||
fp1 = compute_state_fingerprint(
|
||||
surface="windows",
|
||||
window={"title": "App", "class_name": "X", "pid": 111, "hwnd": 222},
|
||||
locatable=base_loc,
|
||||
)
|
||||
fp2 = compute_state_fingerprint(
|
||||
surface="windows",
|
||||
window={"title": "App", "class_name": "X", "pid": 999, "hwnd": 888},
|
||||
locatable=base_loc,
|
||||
)
|
||||
self.assertEqual(fp1, fp2)
|
||||
|
||||
def test_url_changes_fingerprint(self) -> None:
|
||||
loc = [{"control_type": "button", "name": "A", "value": "", "automation_id": "a"}]
|
||||
a = compute_state_fingerprint(surface="browser", url="https://a.test/1", locatable=loc)
|
||||
b = compute_state_fingerprint(surface="browser", url="https://a.test/2", locatable=loc)
|
||||
self.assertNotEqual(a, b)
|
||||
|
||||
def test_anonymous_dom_elements_do_not_merge_across_paths(self) -> None:
|
||||
common = dict(surface="browser", role="span", name="", value="")
|
||||
a = element_fingerprint(**common, dom_fingerprint="span|#video-tab")
|
||||
b = element_fingerprint(**common, dom_fingerprint="span|#comment-input")
|
||||
self.assertNotEqual(a, b)
|
||||
|
||||
def test_contenteditable_value_is_not_identity(self) -> None:
|
||||
common = dict(
|
||||
surface="browser",
|
||||
role="combobox",
|
||||
contenteditable=True,
|
||||
dom_fingerprint="div|#comment-input [contenteditable]",
|
||||
)
|
||||
self.assertEqual(
|
||||
element_fingerprint(**common, name="first", value="first"),
|
||||
element_fingerprint(**common, name="second", value="second"),
|
||||
)
|
||||
|
||||
def test_link_href_is_identity_when_visible_text_changes(self) -> None:
|
||||
common = {
|
||||
"role": "link",
|
||||
"tag": "a",
|
||||
"href": "/detail/42",
|
||||
"css": "#results > li.card > a",
|
||||
}
|
||||
self.assertEqual(
|
||||
element_identity_key(
|
||||
{**common, "name": "Result with 10 likes"},
|
||||
surface="browser",
|
||||
page_url="https://example.test/search?page=1",
|
||||
),
|
||||
element_identity_key(
|
||||
{**common, "name": "Result with 11 likes"},
|
||||
surface="browser",
|
||||
page_url="https://example.test/search?page=2",
|
||||
),
|
||||
)
|
||||
|
||||
def test_value_change_detected(self) -> None:
|
||||
a = compute_state_fingerprint(
|
||||
surface="windows",
|
||||
locatable=[{"control_type": "Edit", "name": "box", "value": "1", "automation_id": "e"}],
|
||||
)
|
||||
b = compute_state_fingerprint(
|
||||
surface="windows",
|
||||
locatable=[{"control_type": "Edit", "name": "box", "value": "2", "automation_id": "e"}],
|
||||
)
|
||||
self.assertNotEqual(a, b)
|
||||
|
||||
|
||||
class TestDedupe(unittest.TestCase):
|
||||
def test_same_state_deduped(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
svc = SnapshotService(Path(tmp), persist_evidence=False)
|
||||
|
||||
class Dump:
|
||||
surface = "browser"
|
||||
driver = "playwright"
|
||||
window = {"title": "T", "class_name": "P"}
|
||||
tree = {}
|
||||
index = []
|
||||
locatable = [
|
||||
{"control_type": "button", "name": "Go", "value": "", "automation_id": "go"}
|
||||
]
|
||||
stats = {"locatable_count": 1, "nodes_kept": 1}
|
||||
selection = {}
|
||||
url = "https://example.com"
|
||||
page_title = "T"
|
||||
screenshot_bytes = None
|
||||
use_hwnd_screenshot = False
|
||||
|
||||
s1 = svc.capture_from_dump(Dump(), change_reason="entry")
|
||||
s2 = svc.capture_from_dump(Dump(), change_reason="heartbeat")
|
||||
self.assertEqual(s1.id, s2.id)
|
||||
self.assertEqual(s2.observation_count, 2)
|
||||
self.assertEqual(len(svc.state_ids()), 1)
|
||||
|
||||
|
||||
class TestStateDiff(unittest.TestCase):
|
||||
def test_diff_and_postconditions(self) -> None:
|
||||
before = {
|
||||
"url": "https://a.test/1",
|
||||
"page_title": "One",
|
||||
"window": {"class_name": "WeWorkWindow", "title": "企业微信", "hwnd": 1},
|
||||
"locatable": [
|
||||
{
|
||||
"control_type": "Edit",
|
||||
"name": "searchedit",
|
||||
"value": "q",
|
||||
"automation_id": "searchedit",
|
||||
}
|
||||
],
|
||||
"stats": {"locatable_count": 1},
|
||||
}
|
||||
after = {
|
||||
"url": "https://a.test/1",
|
||||
"page_title": "Chat",
|
||||
"window": {"class_name": "WeWorkWindow", "title": "企业微信", "hwnd": 1},
|
||||
"locatable": [
|
||||
{
|
||||
"control_type": "Edit",
|
||||
"name": "send_rich_edit",
|
||||
"value": "",
|
||||
"automation_id": "send_rich_edit",
|
||||
}
|
||||
],
|
||||
"stats": {"locatable_count": 1},
|
||||
}
|
||||
changes = diff_states(before, after)
|
||||
kinds = {c.kind for c in changes}
|
||||
self.assertIn("title_changed", kinds)
|
||||
self.assertIn("element_appeared", kinds)
|
||||
self.assertIn("element_disappeared", kinds)
|
||||
props = propose_postconditions(changes)
|
||||
self.assertTrue(all(p.status == "proposed" for p in props))
|
||||
self.assertTrue(any("appeared" in p.description.lower() or "出现" in p.description or "appeared" in p.description for p in props) or props)
|
||||
|
||||
|
||||
class TestRedaction(unittest.TestCase):
|
||||
def test_password_redacted(self) -> None:
|
||||
out, sens = redact_input_payload(
|
||||
{"text": "secret123", "name": "password"},
|
||||
field_name="password",
|
||||
input_type="password",
|
||||
)
|
||||
self.assertTrue(sens)
|
||||
self.assertEqual(out["text"], "[REDACTED]")
|
||||
|
||||
def test_phone_redacted(self) -> None:
|
||||
self.assertIn("[REDACTED]", redact_text("call 13800138000 please"))
|
||||
|
||||
def test_raw_tree_and_index_are_sanitized_before_write(self) -> None:
|
||||
secret = "Bearer_abcdefghijklmnopqrstuvwxyz123456"
|
||||
|
||||
class SensitiveDump:
|
||||
surface = "browser"
|
||||
driver = "playwright"
|
||||
window = {"title": "Login"}
|
||||
tree = {
|
||||
"role": "textbox",
|
||||
"name": "password",
|
||||
"input_type": "password",
|
||||
"value": secret,
|
||||
}
|
||||
index = [dict(tree)]
|
||||
locatable = [dict(tree)]
|
||||
stats = {}
|
||||
selection = {}
|
||||
url = "https://example.com/login"
|
||||
page_title = "Login"
|
||||
screenshot_bytes = None
|
||||
use_hwnd_screenshot = False
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
svc = SnapshotService(root)
|
||||
svc.capture_from_dump(
|
||||
SensitiveDump(), change_reason="sensitive", force_persist=True
|
||||
)
|
||||
persisted = "\n".join(
|
||||
p.read_text(encoding="utf-8", errors="ignore")
|
||||
for p in root.rglob("*")
|
||||
if p.is_file()
|
||||
)
|
||||
self.assertNotIn(secret, persisted)
|
||||
|
||||
|
||||
class TestActionBeforeAfter(unittest.TestCase):
|
||||
def test_click_links_states(self) -> None:
|
||||
act = ActionRecord(
|
||||
action_type="click",
|
||||
before_state_id="st_before",
|
||||
after_state_id="st_after",
|
||||
target_snapshot={"name": "btn"},
|
||||
)
|
||||
self.assertEqual(act.before_state_id, "st_before")
|
||||
self.assertEqual(act.after_state_id, "st_after")
|
||||
self.assertEqual(act.capability, "implemented")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1513
tools/ui_inspector/tests/test_recorder_adapters.py
Normal file
1513
tools/ui_inspector/tests/test_recorder_adapters.py
Normal file
File diff suppressed because it is too large
Load Diff
1
tools/ui_inspector/tests/unit/__init__.py
Normal file
1
tools/ui_inspector/tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
tools/ui_inspector/tests/unit/core/__init__.py
Normal file
1
tools/ui_inspector/tests/unit/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from core.pipeline.event_journal import EventJournal
|
||||
from core.policies.element_identity import element_identity_key
|
||||
|
||||
|
||||
def test_journal_is_append_only(tmp_path: Path) -> None:
|
||||
journal = EventJournal(tmp_path)
|
||||
assert journal.append({"kind": "pointerdown"}) == 1
|
||||
assert journal.append({"kind": "click"}) == 2
|
||||
rows = [json.loads(line) for line in journal.path.read_text(encoding="utf-8").splitlines()]
|
||||
assert [row["journal_sequence"] for row in rows] == [1, 2]
|
||||
assert [row["event"]["kind"] for row in rows] == ["pointerdown", "click"]
|
||||
|
||||
|
||||
def test_focus_classes_and_query_strings_do_not_split_identity() -> None:
|
||||
first = {
|
||||
"role": "textbox", "tag": "input", "placeholder": "Search",
|
||||
"css": "#header input.search",
|
||||
}
|
||||
focused = {
|
||||
**first,
|
||||
"class_name": "search focused hash-123",
|
||||
"css": "#header input.search.focused.hash-123",
|
||||
}
|
||||
one = element_identity_key(first, surface="browser", page_url="https://example.com/home?a=1")
|
||||
two = element_identity_key(focused, surface="browser", page_url="https://example.com/results?q=x")
|
||||
assert one == two
|
||||
|
||||
70
tools/ui_inspector/tests/unit/core/test_target_scoring.py
Normal file
70
tools/ui_inspector/tests/unit/core/test_target_scoring.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from core.policies.target_scoring import resolve_browser_target
|
||||
|
||||
|
||||
def test_huge_control_named_ancestor_never_beats_comment_hit() -> None:
|
||||
hit = {
|
||||
"tag": "path", "role": "path", "cursor": "pointer",
|
||||
"rect": {"left": 1047, "top": 545, "right": 1070, "bottom": 568, "width": 23, "height": 23},
|
||||
"css": "#comment-input-container .send > svg > path",
|
||||
}
|
||||
huge = {
|
||||
"tag": "div", "role": "div", "class_name": "playerControlHeight",
|
||||
"rect": {"left": 160, "top": -244, "right": 1440, "bottom": 6698, "width": 1280, "height": 6942},
|
||||
"css": "#root .playerControlHeight",
|
||||
}
|
||||
target, scored = resolve_browser_target(hit, [hit, huge], x=1056, y=558)
|
||||
assert target["tag"] == "path"
|
||||
assert any("oversized_ancestor" in row["reasons"] for row in scored)
|
||||
|
||||
|
||||
def test_nearest_semantic_wrapper_beats_svg_path() -> None:
|
||||
hit = {
|
||||
"tag": "path", "role": "path", "cursor": "pointer",
|
||||
"rect": {"left": 10, "top": 10, "right": 30, "bottom": 30, "width": 20, "height": 20},
|
||||
}
|
||||
wrapper = {
|
||||
"tag": "span", "role": "span", "cursor": "pointer", "class_name": "sendControl",
|
||||
"rect": {"left": 5, "top": 5, "right": 35, "bottom": 35, "width": 30, "height": 30},
|
||||
}
|
||||
target, _ = resolve_browser_target(hit, [hit, wrapper], x=20, y=20)
|
||||
assert target["class_name"] == "sendControl"
|
||||
|
||||
|
||||
def test_contenteditable_root_beats_transient_inner_block() -> None:
|
||||
inner = {
|
||||
"tag": "div", "role": "textbox", "contenteditable": True,
|
||||
"contenteditable_attr": False,
|
||||
"rect": {"left": 10, "top": 10, "right": 300, "bottom": 35, "width": 290, "height": 25},
|
||||
}
|
||||
root = {
|
||||
"tag": "div", "role": "combobox", "explicit_role": "combobox",
|
||||
"contenteditable": True, "contenteditable_attr": True,
|
||||
"rect": {"left": 10, "top": 10, "right": 300, "bottom": 35, "width": 290, "height": 25},
|
||||
}
|
||||
target, _ = resolve_browser_target(inner, [inner, root], x=20, y=20)
|
||||
assert target["contenteditable_attr"] is True
|
||||
|
||||
|
||||
def test_unique_clickable_wrapper_beats_ambiguous_graphic_leaf() -> None:
|
||||
rect_path = {"left": 1047, "top": 345, "right": 1070, "bottom": 368, "width": 23, "height": 23}
|
||||
rect_wrapper = {"left": 1041, "top": 338, "right": 1077, "bottom": 374, "width": 36, "height": 36}
|
||||
path = {
|
||||
"tag": "path", "role": "path", "cursor": "pointer",
|
||||
"rect": rect_path, "css_count": 2,
|
||||
}
|
||||
svg = {
|
||||
"tag": "svg", "role": "svg", "cursor": "pointer",
|
||||
"rect": rect_wrapper, "css_count": 1,
|
||||
}
|
||||
wrapper = {
|
||||
"tag": "span", "role": "span", "cursor": "pointer",
|
||||
"rect": rect_wrapper, "css_count": 1,
|
||||
"stable_css": "#comment-form > div.controls > span",
|
||||
"stable_css_count": 1,
|
||||
}
|
||||
target, scored = resolve_browser_target(
|
||||
path, [path, svg, wrapper], x=1055, y=352
|
||||
)
|
||||
assert target["tag"] == "span"
|
||||
assert any("graphic_leaf" in row["reasons"] for row in scored)
|
||||
assert any("event_time_unique_locator" in row["reasons"] for row in scored)
|
||||
120
tools/ui_inspector/tests/unit/test_app_layout.py
Normal file
120
tools/ui_inspector/tests/unit/test_app_layout.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_HERE = Path(__file__).resolve().parents[2]
|
||||
if str(_HERE) not in sys.path:
|
||||
sys.path.insert(0, str(_HERE))
|
||||
|
||||
from app import (
|
||||
_DEFAULT_FLOW_NAME_SLUGS,
|
||||
_default_window_geometry,
|
||||
_flow_name_for_mode,
|
||||
_format_geometry,
|
||||
_format_live_action,
|
||||
_merge_current_live_action,
|
||||
)
|
||||
import artifacts as art
|
||||
import target
|
||||
import user_prefs
|
||||
|
||||
|
||||
def test_default_geometry_is_narrow_tall_and_leaves_title_bar_clear() -> None:
|
||||
width, height, x, y = _default_window_geometry((0, 0, 1920, 1040))
|
||||
|
||||
assert (width, height) == (576, 951)
|
||||
assert (x, y) == (1328, 73)
|
||||
assert y >= 64
|
||||
assert x + width == 1920 - 16
|
||||
|
||||
|
||||
def test_default_geometry_supports_small_and_left_side_monitors() -> None:
|
||||
width, height, x, y = _default_window_geometry(
|
||||
(-1366, 0, 0, 728), side="left"
|
||||
)
|
||||
|
||||
assert (width, height) == (560, 648)
|
||||
assert (x, y) == (-1350, 64)
|
||||
assert _format_geometry(width, height, x, y) == "560x648-1350+64"
|
||||
|
||||
|
||||
def test_flow_name_defaults_follow_pick_mode() -> None:
|
||||
assert _flow_name_for_mode(target.SURFACE_BROWSER) == "browser"
|
||||
assert _flow_name_for_mode(target.SURFACE_WINDOWS) == "desktop"
|
||||
assert _flow_name_for_mode(target.SURFACE_ANDROID) == "mobile"
|
||||
assert _DEFAULT_FLOW_NAME_SLUGS == frozenset({"browser", "desktop", "mobile"})
|
||||
|
||||
|
||||
def test_local_ts_is_compact_yyyymmddhhmmss() -> None:
|
||||
stamp = art._local_ts()
|
||||
assert len(stamp) == 14
|
||||
assert stamp.isdigit()
|
||||
assert "T" not in stamp
|
||||
assert "+" not in stamp
|
||||
|
||||
|
||||
def test_create_flow_dir_uses_compact_timestamp(tmp_path: Path) -> None:
|
||||
flow_dir = art.create_flow(tmp_path, flow_name="browser")
|
||||
name = flow_dir.name
|
||||
assert name.endswith("_browser")
|
||||
stamp = name[: -len("_browser")]
|
||||
assert len(stamp) == 14
|
||||
assert stamp.isdigit()
|
||||
|
||||
|
||||
def test_hidden_legacy_recording_preference_migrates_to_auto(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
prefs_path = tmp_path / "user_prefs.json"
|
||||
prefs_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mode": "browser",
|
||||
"start_url": "https://example.com",
|
||||
"recording_mode": "legacy_interval",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(user_prefs, "_PREFS_PATH", prefs_path)
|
||||
|
||||
assert user_prefs.load_prefs()["recording_mode"] == "auto"
|
||||
|
||||
|
||||
def test_live_action_formatter_is_surface_neutral_and_two_phase() -> None:
|
||||
text = _format_live_action(
|
||||
{
|
||||
"sequence": 3,
|
||||
"action_type": "click",
|
||||
"phase": "captured",
|
||||
"surface": "browser",
|
||||
"target_name": "提交",
|
||||
"target_type": "button",
|
||||
}
|
||||
)
|
||||
assert "#3 点击" in text
|
||||
assert "browser" in text
|
||||
assert "证据采集中" in text
|
||||
assert "等待异步证据" in text
|
||||
|
||||
|
||||
def test_current_live_action_overwrites_without_allowing_late_evidence_to_regress() -> None:
|
||||
first = {"id": "action-1", "sequence": 1, "phase": "captured"}
|
||||
first_evidence = {"id": "action-1", "sequence": 1, "phase": "evidence"}
|
||||
second = {"id": "action-2", "sequence": 2, "phase": "captured"}
|
||||
|
||||
current = _merge_current_live_action(None, first)
|
||||
current = _merge_current_live_action(current, first_evidence)
|
||||
assert current == first_evidence
|
||||
|
||||
current = _merge_current_live_action(current, second)
|
||||
assert current == second
|
||||
|
||||
current = _merge_current_live_action(current, first_evidence)
|
||||
assert current == second
|
||||
|
||||
|
||||
def test_current_live_action_ignores_updates_without_an_action_id() -> None:
|
||||
current = {"id": "action-2", "sequence": 2, "phase": "captured"}
|
||||
|
||||
assert _merge_current_live_action(current, {"sequence": 3}) is current
|
||||
Reference in New Issue
Block a user