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>
296 lines
11 KiB
Python
296 lines
11 KiB
Python
"""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()
|