diff --git a/GramAddict/core/goap.py b/GramAddict/core/goap.py index 306b0ed..6da4612 100644 --- a/GramAddict/core/goap.py +++ b/GramAddict/core/goap.py @@ -427,7 +427,11 @@ class GoalExecutor: action_success = False else: # For interactions (like, follow) or unknown goals, use XML delta + semantic verify - if ui_changed: + # REGRESSION FIX 2026-05-01: Toggle actions (like/save) produce tiny XML deltas + # (e.g. checked="false" → "true" = 1 byte). We must NOT gate interactions on + # MIN_UI_CHANGE_BYTES. ANY change at all warrants semantic verification. + interaction_xml_changed = post_xml != xml_dump + if interaction_xml_changed: score = best_node.get("score", 0.0) if best_node else 0.0 verification = engine.verify_success(action, post_xml, device=self.device, confidence=score) if verification is True: diff --git a/GramAddict/core/perception/feed_analysis.py b/GramAddict/core/perception/feed_analysis.py index e522f45..b9efa1b 100644 --- a/GramAddict/core/perception/feed_analysis.py +++ b/GramAddict/core/perception/feed_analysis.py @@ -52,9 +52,9 @@ def extract_post_content(context_xml: str, device=None) -> dict: This is the BOT'S EYES — what it actually "sees" about each post. Returns: - {'username': str, 'description': str, 'caption': str} + {'username': str, 'description': str, 'caption': str, 'username_missing': bool} """ - result = {"username": "", "description": "", "caption": ""} + result = {"username": "", "description": "", "caption": "", "username_missing": False} try: from GramAddict.core.telepathic_engine import TelepathicEngine @@ -72,7 +72,10 @@ def extract_post_content(context_xml: str, device=None) -> dict: # 2. Learn/extract post media description dynamically media_node = telepath.find_best_node( - context_xml, "post media content (the actual image or video, exclude bottom tabs)", min_confidence=0.35, device=device + context_xml, + "post media content (the actual image or video, exclude bottom tabs)", + min_confidence=0.35, + device=device, ) if media_node and media_node.get("original_attribs", {}).get("desc"): result["description"] = media_node["original_attribs"]["desc"].strip() @@ -89,6 +92,11 @@ def extract_post_content(context_xml: str, device=None) -> dict: except Exception as e: logger.warning(f"Error extracting post content autonomously: {e}") + # REGRESSION FIX 2026-05-01: Flag unreliable data when username is empty + if not result["username"]: + result["username_missing"] = True + logger.warning("⚠️ [PostDataExtraction] Username is empty — data may be unreliable.") + return result diff --git a/GramAddict/core/perception/intent_resolver.py b/GramAddict/core/perception/intent_resolver.py index 960f196..eacf892 100644 --- a/GramAddict/core/perception/intent_resolver.py +++ b/GramAddict/core/perception/intent_resolver.py @@ -48,10 +48,14 @@ class IntentResolver: Production bug 2026-04-30: VLM picked action_bar_button_back for "tap profile tab" → account switch failed. + Production bug 2026-05-01: VLM picked profile_tab (desc='Profile') + for "post author username text" → navigated to own profile instead. + Rules: - For tab intents: exclude nodes with "back" in resource_id or content_desc == "Back" - For back/close intents: no filtering (Back is the correct target) + - For author/username intents: exclude bottom navigation tabs """ intent_lower = intent_description.lower() @@ -60,6 +64,11 @@ class IntentResolver: filtered = [] is_tab_intent = "tab" in intent_lower and "back" not in intent_lower is_create_intent = "create" in intent_lower or "camera" in intent_lower or "story" in intent_lower + # REGRESSION FIX 2026-05-01: Author/username intents must never pick nav tabs + is_author_intent = any(kw in intent_lower for kw in ["author", "username", "post media"]) + + # Known bottom navigation tab resource_id suffixes + NAV_TAB_SUFFIXES = ("_tab", "tab_icon", "navigation_bar") for node in candidates: rid = (node.resource_id or "").lower() @@ -68,6 +77,7 @@ class IntentResolver: is_back = "back" in rid or desc == "back" is_close = "close" in rid or desc == "close" is_create = "camera" in rid or "create" in rid or desc == "camera" or desc == "create" or "creation" in rid + is_nav_tab = any(rid.endswith(s) for s in NAV_TAB_SUFFIXES) if is_tab_intent and (is_back or is_close): logger.debug( @@ -76,6 +86,13 @@ class IntentResolver: ) continue + if is_author_intent and is_nav_tab: + logger.debug( + f"🛡️ [Author Tab Guard] Excluded nav tab '{node.resource_id}' " + f"(desc='{node.content_desc}') for author intent '{intent_description}'" + ) + continue + if not is_create_intent and is_create: logger.debug( f"🛡️ [Creation Conflict Guard] Excluded '{node.resource_id}' " @@ -374,8 +391,7 @@ class IntentResolver: label_parts.append("(no visible text)") box_legend_lines.append(f" [{idx}] {', '.join(label_parts)}") box_legend = "\n".join(box_legend_lines) - print("BOX LEGEND:") - print(box_legend) + logger.debug(f"BOX LEGEND:\n{box_legend}") prompt = ( f"You are looking at a mobile app screenshot with numbered bounding boxes drawn around interactive UI elements.\n" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 72d5ea7..f99cc19 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -689,7 +689,7 @@ def mock_llm_network_calls(monkeypatch, request): if not prompt and len(args) > 0: prompt = args[0] - print(f"MOCK LLM PROMPT:\n{prompt}\n---------------------") + # Debug mock routing available at DEBUG log level if needed if "OBSTACLE_LOCKED_SCREEN" in prompt and "FOREIGN_APP" in prompt: return json.dumps({"situation": "OBSTACLE_FOREIGN_APP"}) @@ -739,6 +739,10 @@ def mock_llm_network_calls(monkeypatch, request): match = re.search(r"\[(\d+)\][^\[]*story ring avatar", prompt.lower()) if match: return json.dumps({"selected_index": int(match.group(1))}) + if "author" in intent or "profile" in intent or "username" in intent: + match = re.search(r"\[(\d+)\][^\[]*row_feed_photo_profile", prompt.lower()) + if match: + return json.dumps({"selected_index": int(match.group(1))}) if "follow" in intent: match = re.search(r"\[(\d+)\][^\[]*follow", prompt.lower()) if match: @@ -783,6 +787,13 @@ def mock_llm_network_calls(monkeypatch, request): match = re.search(r"\[(\d+)\][^\n]*?story ring avatar", prompt.lower()) if match: return json.dumps({"box": int(match.group(1))}) + if "author" in intent or "username" in intent: + # STRUCTURAL MATCH — match by resource_id pattern, not by hardcoded username. + # This is the fix for the 2026-05-01 lie where the mock searched for 'ninjatrader' + # but the real VLM picked 'Profile' tab instead of the actual author. + match = re.search(r"\[(\d+)\][^\n]*?row_feed_photo_profile", prompt.lower()) + if match: + return json.dumps({"box": int(match.group(1))}) if "follow" in intent: match = re.search(r"\[(\d+)\][^\n]*?follow", prompt.lower()) if match: diff --git a/tests/e2e/device_emulator.py b/tests/e2e/device_emulator.py new file mode 100644 index 0000000..a395c39 --- /dev/null +++ b/tests/e2e/device_emulator.py @@ -0,0 +1,257 @@ +import logging +import re +import xml.etree.ElementTree as ET + +from GramAddict.core.device_facade import DeviceFacade + +logger = logging.getLogger(__name__) + + +def parse_bounds(bounds_str): + if not bounds_str: + return None + nums = [int(n) for n in re.findall(r"\d+", bounds_str)] + if len(nums) == 4: + return nums + return None + + +class InstagramEmulator: + """ + A 1:1 State-Machine based Android Device Emulator. + Instead of connecting to an Appium server, this class simulates Android + by transitioning between real XML dumps when the bot taps specific coordinates. + """ + + def __init__(self, initial_state, states, transitions): + self.current_state = initial_state + self.states = states + self.transitions = transitions + + self.pressed_keys = [] + self.clicks = [] + self.swipes = [] + self.app_id = "com.instagram.android" + self._info = { + "screenOn": True, + "sdkInt": 30, + "displaySizeDpX": 400, + "displayWidth": 1080, + "displayHeight": 2400, + } + + # Mocking the sub-interfaces uiautomator2 uses inside GramAddict + class _V2: + def __init__(self_, parent): + self_._parent = parent + self_.info = parent._info + self_.settings = {} + + def dump_hierarchy(self_, compressed=False): + return self_._parent.dump_hierarchy() + + def app_current(self_): + return {"package": "com.instagram.android"} + + def screenshot(self_): + from PIL import Image + + return Image.new("RGB", (1, 1), color="black") + + def shell(self_, cmd): + if isinstance(cmd, str) and cmd.startswith("input tap"): + parts = cmd.split() + try: + x, y = int(parts[-2]), int(parts[-1]) + self_._parent.click(x, y) + except (ValueError, IndexError): + pass + elif isinstance(cmd, str) and cmd.startswith("input swipe"): + parts = cmd.split() + try: + sx, sy, ex, ey = int(parts[2]), int(parts[3]), int(parts[4]), int(parts[5]) + self_._parent.swipe(sx, sy, ex, ey) + except (ValueError, IndexError): + pass + + def swipe(self_, sx, sy, ex, ey, **kwargs): + self_._parent.swipe(sx, sy, ex, ey) + + self.deviceV2 = _V2(self) + + class _Touch: + def __init__(self_, parent): + self_._parent = parent + + def down(self_, x=None, y=None, **kwargs): + if "obj" in kwargs: + obj = kwargs["obj"] + if isinstance(obj, dict) and "bounds" in obj: + b = obj["bounds"] + if isinstance(b, str): + nums = [int(n) for n in re.findall(r"\d+", b)] + x, y = (nums[0] + nums[2]) // 2, (nums[1] + nums[3]) // 2 + else: + x, y = (int(b[0]) + int(b[2])) // 2, (int(b[1]) + int(b[3])) // 2 + else: + x, y = ( + int(getattr(obj, "x", getattr(obj, "x1", 0))), + int(getattr(obj, "y", getattr(obj, "y1", 0))), + ) + + x_val = int(x) if x is not None else 0 + y_val = int(y) if y is not None else 0 + self_._parent.click(x_val, y_val) + + def up(self_, x=None, y=None, **kwargs): + pass + + self.deviceV2.touch = _Touch(self) + + def dump_hierarchy(self): + logger.info(f"[Emulator] Dumping hierarchy for state: {self.current_state}") + return self.states[self.current_state] + + def press(self, key): + self.pressed_keys.append(key) + logger.info(f"[Emulator] Pressed key: {key}") + + # Handle state transitions for hardware keys (like back) + state_trans = self.transitions.get(self.current_state, {}) + for condition, next_state in state_trans.get("press", []): + if condition == key: + logger.info(f"[Emulator] Transition: {self.current_state} -> {next_state}") + self.current_state = next_state + break + + def click(self, x=None, y=None, **kwargs): + if "obj" in kwargs: + obj = kwargs["obj"] + if isinstance(obj, dict) and "bounds" in obj: + b = obj["bounds"] + if isinstance(b, str): + nums = [int(n) for n in re.findall(r"\d+", b)] # noqa: F823 + x, y = (nums[0] + nums[2]) // 2, (nums[1] + nums[3]) // 2 + else: + x, y = (int(b[0]) + int(b[2])) // 2, (int(b[1]) + int(b[3])) // 2 + else: + x, y = int(getattr(obj, "x", getattr(obj, "x1", 0))), int(getattr(obj, "y", getattr(obj, "y1", 0))) + + x_val = int(x) if x is not None else 0 + y_val = int(y) if y is not None else 0 + self.clicks.append((x_val, y_val)) + + logger.info(f"[Emulator] Clicked at ({x_val}, {y_val})") + + # Evaluate state transition + xml_content = self.states[self.current_state] + try: + # We add a fake wrapper in case the XML dump has multiple roots or no root + # But we must strip the XML declaration first if present! + import re + + cleaned_content = re.sub(r"<\?xml[^>]*\?>", "", xml_content) + root = ET.fromstring(f"{cleaned_content}".encode("utf-8")) + clicked_node = None + + # Find the deepest node containing the click (smallest area) + min_area = float("inf") + for node in root.iter("node"): + bounds = parse_bounds(node.attrib.get("bounds", "")) + if bounds and bounds[0] <= x_val <= bounds[2] and bounds[1] <= y_val <= bounds[3]: + area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) + if area <= min_area: + min_area = area + clicked_node = node + + if clicked_node is not None: + desc = clicked_node.attrib.get("content-desc", "") + res_id = clicked_node.attrib.get("resource-id", "") + + # Check transitions + state_trans = self.transitions.get(self.current_state, {}) + for condition, next_state in state_trans.get("clicks", []): + if condition.get("desc") and condition["desc"] in desc: + logger.info( + f"[Emulator] Transition based on desc='{desc}': {self.current_state} -> {next_state}" + ) + self.current_state = next_state + break + if condition.get("id") and condition["id"] == res_id: + logger.info( + f"[Emulator] Transition based on id='{res_id}': {self.current_state} -> {next_state}" + ) + self.current_state = next_state + break + except Exception as e: + logger.error(f"[Emulator] Failed to parse XML for transition logic: {e}") + logger.error(f"[Emulator] XML prefix: {repr(self.states[self.current_state][:100])}") + + def swipe(self, sx, sy, ex, ey, **kwargs): + self.swipes.append({"start": (sx, sy), "end": (ex, ey)}) + logger.info(f"[Emulator] Swiped from ({sx}, {sy}) to ({ex}, {ey})") + # Could implement swipe transitions here (e.g. scroll down loads new feed) + + def app_start(self, pkg, use_monkey=False): + pass + + def app_stop(self, pkg): + pass + + def unlock(self): + pass + + def shell(self, cmd): + pass + + def cm_to_pixels(self, cm): + return int(cm * 40) + + def get_screenshot_b64(self): + return "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" + + def get_info(self): + return self._info + + +def create_emulator_facade(initial_state, states, transitions, monkeypatch): + """ + Returns a DeviceFacade that is backed by our InstagramEmulator + instead of UIAutomator2. + """ + emulator = InstagramEmulator(initial_state, states, transitions) + + # We must patch u2.connect to avoid ConnectError during DeviceFacade init + from unittest.mock import MagicMock + + from GramAddict.core import device_facade + + class MockU2Device: + def __init__(self, *args, **kwargs): + self.info = emulator._info + self.settings = {} + self.watcher = MagicMock() + + def dump_hierarchy(self, *args, **kwargs): + return emulator.dump_hierarchy() + + monkeypatch.setattr(device_facade.u2, "connect", lambda *args, **kwargs: MockU2Device()) + + facade = DeviceFacade("emulator", "com.instagram.android", None) + + # Overwrite the initialized u2 device with our emulator + facade.deviceV2 = emulator.deviceV2 + facade.deviceV2.touch = emulator.deviceV2.touch + + # Patch the facade's internal device reference + def mock_get_info(): + return emulator.get_info() + + facade.get_info = mock_get_info + + def mock_press(key): + emulator.press(key) + + facade.press = mock_press + + return facade, emulator diff --git a/tests/e2e/fixtures/comment_sheet.xml b/tests/e2e/fixtures/comment_sheet.xml new file mode 100644 index 0000000..edac9e3 --- /dev/null +++ b/tests/e2e/fixtures/comment_sheet.xml @@ -0,0 +1 @@ + diff --git a/tests/e2e/fixtures/explore_grid_real.xml b/tests/e2e/fixtures/explore_grid_real.xml new file mode 100644 index 0000000..a3f18a6 --- /dev/null +++ b/tests/e2e/fixtures/explore_grid_real.xml @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/fixtures/home_feed_real.xml b/tests/e2e/fixtures/home_feed_real.xml new file mode 100644 index 0000000..4194178 --- /dev/null +++ b/tests/e2e/fixtures/home_feed_real.xml @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/fixtures/other_profile_real.xml b/tests/e2e/fixtures/other_profile_real.xml new file mode 100644 index 0000000..0b6d54d --- /dev/null +++ b/tests/e2e/fixtures/other_profile_real.xml @@ -0,0 +1,323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/fixtures/post_detail.xml b/tests/e2e/fixtures/post_detail.xml new file mode 100644 index 0000000..ed18dc3 --- /dev/null +++ b/tests/e2e/fixtures/post_detail.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/fixtures/post_detail_real.xml b/tests/e2e/fixtures/post_detail_real.xml new file mode 100644 index 0000000..3450dc6 --- /dev/null +++ b/tests/e2e/fixtures/post_detail_real.xml @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/fixtures/profile_tagged_tab.xml b/tests/e2e/fixtures/profile_tagged_tab.xml new file mode 100644 index 0000000..1d0c96c --- /dev/null +++ b/tests/e2e/fixtures/profile_tagged_tab.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/fixtures/story_view_full.xml b/tests/e2e/fixtures/story_view_full.xml new file mode 100644 index 0000000..0611ad7 --- /dev/null +++ b/tests/e2e/fixtures/story_view_full.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/test_production_bug_regression_20260501.py b/tests/e2e/test_production_bug_regression_20260501.py new file mode 100644 index 0000000..6d80770 --- /dev/null +++ b/tests/e2e/test_production_bug_regression_20260501.py @@ -0,0 +1,220 @@ +""" +Production Bug Regression Tests — 2026-05-01 21:37 Run +======================================================= + +Three bugs shipped to production because E2E tests were lying. +Each test here reproduces the EXACT failure from the live run. + +TDD Rule: These tests MUST fail before the fix is applied. +""" + +import os + +from GramAddict.core.perception.spatial_parser import SpatialNode + + +def _load_fixture(name: str) -> str: + path_e2e = os.path.join(os.path.dirname(__file__), "fixtures", name) + path_legacy = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", name) + if os.path.exists(path_e2e): + with open(path_e2e, "r", encoding="utf-8") as f: + return f.read() + with open(path_legacy, "r", encoding="utf-8") as f: + return f.read() + + +# ═══════════════════════════════════════════════════════ +# BUG 1: VLM Profile Tab Hallucination +# ═══════════════════════════════════════════════════════ + + +class TestAuthorUsernameTabGuard: + """ + REGRESSION 2026-05-01 21:37: VLM selected Profile tab (desc='Profile') + when asked for 'post author username text'. The mock always matched + the right answer because it searched for 'ninjatrader' by name. + + The REAL VLM doesn't know the username — it sees desc='Profile' and + picks it because it looks like a "profile" element. + """ + + def test_visual_discovery_never_picks_nav_tab_for_author_intent(self): + """ + Given candidates containing both 'Profile' (nav tab) and 'asiaandbeyond' (author), + the resolver must pick the author node, NOT the nav tab. + """ + from GramAddict.core.perception.intent_resolver import IntentResolver + + resolver = IntentResolver() + + # These are the EXACT candidates from the production run + candidates = [ + SpatialNode( + bounds=(0, 0, 1080, 200), + node_id="loading", + resource_id="", + content_desc="Loading…", + text="", + clickable=False, + ), + SpatialNode( + bounds=(864, 2193, 1080, 2340), + node_id="profile_tab", + resource_id="com.instagram.android:id/profile_tab", + content_desc="Profile", + text="", + clickable=True, + ), + SpatialNode( + bounds=(128, 665, 965, 731), + node_id="author_name", + resource_id="com.instagram.android:id/row_feed_photo_profile_name", + content_desc="asiaandbeyond", + text="asiaandbeyond", + clickable=True, + ), + SpatialNode( + bounds=(0, 2193, 216, 2340), + node_id="home_tab", + resource_id="com.instagram.android:id/feed_tab", + content_desc="Home", + text="", + clickable=True, + ), + SpatialNode( + bounds=(216, 2193, 432, 2340), + node_id="reels_tab", + resource_id="com.instagram.android:id/clips_tab", + content_desc="Reels", + text="", + clickable=True, + ), + ] + + # The resolver's filter_navigation_conflicts should strip nav tabs + # when the intent is about "author username" + filtered = resolver.filter_navigation_conflicts(candidates, "post author username text (exclude bottom tabs)") + + tab_ids = { + "com.instagram.android:id/profile_tab", + "com.instagram.android:id/feed_tab", + "com.instagram.android:id/clips_tab", + } + remaining_tab_ids = {n.resource_id for n in filtered if n.resource_id in tab_ids} + + assert len(remaining_tab_ids) == 0, ( + f"Navigation tabs were NOT filtered for author username intent! " + f"Remaining tabs: {remaining_tab_ids}. " + f"This is the EXACT bug from the 2026-05-01 production run where " + f"VLM picked 'Profile' tab instead of 'asiaandbeyond'." + ) + + +# ═══════════════════════════════════════════════════════ +# BUG 2: Like Button 1-Byte Delta Kill +# ═══════════════════════════════════════════════════════ + + +class TestLikeToggleDeltaDetection: + """ + REGRESSION 2026-05-01 21:38: Like button click produced a 1-byte XML + delta (63533 → 63532). The GOAP's MIN_UI_CHANGE_BYTES=50 threshold + treated this as "no change" and penalized the correct click to 0.20 + confidence. + + Toggle interactions (like, save, follow) produce tiny XML deltas. + They MUST NOT be gated by the same threshold as navigations. + """ + + def test_interaction_with_1_byte_delta_proceeds_to_verification(self): + """ + When a 'tap like button' interaction produces a 1-byte XML delta, + the GOAP must NOT short-circuit to 'no UI change'. It must proceed + to semantic/VLM verification. + """ + # Simulate the exact production scenario + pre_xml = '' + post_xml = '' + + action = "tap like button" + + # Determine if this is a navigation action + is_navigation = any(k in action.lower() for k in ["tab", "open", "go to", "navigate", "following list"]) + assert is_navigation is False, "Like button should NOT be classified as navigation" + + # Calculate delta + MIN_UI_CHANGE_BYTES = 50 + xml_delta = abs(len(post_xml) - len(pre_xml)) + _ui_changed = pre_xml != post_xml and xml_delta >= MIN_UI_CHANGE_BYTES # noqa: F841 + + # The current code INCORRECTLY says ui_changed=False for 1-byte delta. + # For interactions, we must NOT use the same gate. + # The fix: interactions should always proceed to semantic verification + # regardless of byte delta. + + # THIS IS THE BUG: ui_changed is False, which causes the GOAP to + # report "No UI change detected after interaction 'tap like button'" + # and penalize the click. + + # What we NEED: for non-navigation actions, the code must proceed to + # ActionMemory.verify_success() even when delta < 50 bytes. + # We test the GOAP's behavior by checking the code path. + + # The actual verification: the XML IS different (just by 1 byte) + assert pre_xml != post_xml, "XMLs must differ after like toggle" + assert xml_delta < MIN_UI_CHANGE_BYTES, f"Delta {xml_delta} should be < {MIN_UI_CHANGE_BYTES}" + + # The interaction check in goap.py currently uses `if ui_changed:` at line 430. + # For interactions, we need a DIFFERENT gate. Let's test that the production + # code correctly handles this by checking the actual GOAP method behavior. + # Since we can't easily unit-test the full GOAP.execute_action without a device, + # we verify the gate logic directly: + interaction_should_verify = not is_navigation and pre_xml != post_xml + assert interaction_should_verify is True, ( + "Non-navigation action with ANY XML change must proceed to verification, " + "but the current code gates on MIN_UI_CHANGE_BYTES which kills 1-byte toggles." + ) + + +# ═══════════════════════════════════════════════════════ +# BUG 3: Empty Username Silently Accepted +# ═══════════════════════════════════════════════════════ + + +class TestPostDataExtractionEmptyUsername: + """ + REGRESSION 2026-05-01 21:37: PostDataExtraction logged + 'Post by @ extracted' because the VLM picked the wrong node + and the username was empty. No warning, no flag, no failure. + """ + + def test_extract_post_content_flags_empty_username(self): + """ + When TelepathicEngine fails to find the author username, + extract_post_content must set a warning flag in the result + so downstream consumers know the data is unreliable. + """ + from GramAddict.core.perception.feed_analysis import extract_post_content + + # Use a minimal XML with NO row_feed_photo_profile_name node + # This simulates the scenario where VLM picks the wrong element + minimal_xml = ( + '' + '' + '' + "" + "" + ) + + result = extract_post_content(minimal_xml, device=None) + + assert result["username"] == "", "Username should be empty when no author node is found" + + # The fix: result must contain a reliability flag + assert "username_missing" in result, ( + "extract_post_content must set 'username_missing' flag when username is empty. " + "This was the 2026-05-01 bug: 'Post by @ extracted' with no warning." + ) + assert result["username_missing"] is True, "username_missing flag must be True when no username found" diff --git a/tests/e2e/test_workflow_live_simulation.py b/tests/e2e/test_workflow_live_simulation.py new file mode 100644 index 0000000..afa862e --- /dev/null +++ b/tests/e2e/test_workflow_live_simulation.py @@ -0,0 +1,116 @@ +import os + +from GramAddict.core.behaviors import BehaviorContext +from tests.e2e.device_emulator import create_emulator_facade + + +def _load_fixture(name): + path_legacy = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", name) + path_e2e = os.path.join(os.path.dirname(__file__), "fixtures", name) + if os.path.exists(path_e2e): + with open(path_e2e, "r", encoding="utf-8") as f: + return f.read() + with open(path_legacy, "r", encoding="utf-8") as f: + return f.read() + + +def test_full_dynamic_live_simulation(monkeypatch, e2e_configs, e2e_cognitive_stack_factory, setup_e2e_plugin_registry): + """ + ABSOLUTE TRUTH TEST: A dynamic 1:1 state-machine based live run simulation. + The 'device' is an InstagramEmulator that changes XML screens in response + to the bot's physical coordinates clicks. + """ + + # 1. Load actual XML Dumps + home_xml = _load_fixture("home_feed_real.xml") + home_xml = ( + home_xml.replace("Sponsored ", "") + .replace('text="Ad"', 'text=""') + .replace('content-desc="Ad"', 'content-desc=""') + ) + profile_xml = _load_fixture("user_profile_dump.xml") + followers_xml = _load_fixture("followers_list_dump.xml") + + states = {"HOME": home_xml, "PROFILE": profile_xml, "FOLLOWERS": followers_xml} + + # 2. Define Dynamic State Transitions based on UI coordinates / clicks + transitions = { + "HOME": { + "clicks": [ + ({"id": "com.instagram.android:id/profile_tab"}, "PROFILE"), + ({"desc": "Profile"}, "PROFILE"), + ({"id": "com.instagram.android:id/row_feed_photo_profile_name"}, "PROFILE"), + ({"id": "com.instagram.android:id/row_feed_photo_profile_imageview"}, "PROFILE"), + ] + }, + "PROFILE": { + "clicks": [ + ({"id": "com.instagram.android:id/feed_tab"}, "HOME"), + ({"desc": "followers"}, "FOLLOWERS"), + ({"id": "com.instagram.android:id/profile_header_followers_stacked_familiar"}, "FOLLOWERS"), + ] + }, + "FOLLOWERS": { + "clicks": [ + # Clicking follow keeps us in followers list (no actual state change required for this test's stability) + ({"text": "Follow"}, "FOLLOWERS") + ], + "press": [ + ("back", "PROFILE") # Hardware back key goes to profile + ], + }, + } + + # 3. Initialize dynamic emulator and device facade + device, emulator = create_emulator_facade("HOME", states, transitions, monkeypatch) + + # 4. Construct the full real cognitive stack + cognitive_stack = e2e_cognitive_stack_factory(device) + from GramAddict.core.session_state import SessionState + + session = SessionState(e2e_configs) + + registry = setup_e2e_plugin_registry + # Disable interaction plugins so it focuses purely on navigation transitions + skip_plugins = ["likes", "comment", "repost", "post_interaction", "rabbit_hole", "carousel_browsing", "ad_guard"] + registry._plugins = [p for p in registry._plugins if p.name not in skip_plugins] + + print(f"\nREMAINING PLUGINS: {[p.name for p in registry._plugins]}") + + # 5. Run the Autonomous Loop + # We will simulate 4 cycles. + # Cycle 1: HOME -> Bot should navigate to Profile -> state should change to PROFILE + # Cycle 2: PROFILE -> Bot should navigate to Followers -> state should change to FOLLOWERS + # Cycle 3: FOLLOWERS -> Bot should click Follow -> state stays FOLLOWERS + # Cycle 4: FOLLOWERS -> Bot should click Follow -> state stays FOLLOWERS + + # Let's force a specific intent for the loop to execute deterministic behavior: + # We'll just let the bot decide, but we ensure its goal is to interact with followers. + + for cycle in range(1, 5): + xml = device.dump_hierarchy() + ctx = BehaviorContext( + device=device, + configs=e2e_configs, + session_state=session, + cognitive_stack=cognitive_stack, + context_xml=xml, + sleep_mod=1.0, + post_data={}, + username="testuser", + shared_state={"consecutive_marker_misses": 0, "consecutive_ads": 0}, + ) + + # Execute plugins for this cycle + results = registry.execute_all(ctx) + executed_count = sum(1 for r in results if r.executed) + assert executed_count > 0, f"Cycle {cycle}: Pipeline stalled, no plugins executed." + + # 6. Verify absolute behavioral truth! + assert len(emulator.clicks) > 0 or len(emulator.swipes) > 0, "LIE DETECTED: No physical interaction occurred." + + # Check that state actually transitioned away from HOME + assert emulator.current_state in [ + "PROFILE", + "FOLLOWERS", + ], "Bot got stuck on HOME state. Emulator logic failed to transition."