diff --git a/GramAddict/core/bot_flow.py b/GramAddict/core/bot_flow.py index 401af90..5c1c74a 100644 --- a/GramAddict/core/bot_flow.py +++ b/GramAddict/core/bot_flow.py @@ -66,12 +66,6 @@ def start_bot(**kwargs): prewarm_ollama_models(configs) except Exception as e: logger.debug(f"Prewarm failed: {e}") - - if getattr(configs.args, "capture_e2e_dumps", False): - device = create_device(configs.device_id, configs.app_id, configs.args) - from GramAddict.core.dump_capturer import capture_all - capture_all(device) - return sessions = PersistentList("sessions", SessionStateEncoder) device = create_device(configs.device_id, configs.app_id, configs.args) diff --git a/GramAddict/core/config.py b/GramAddict/core/config.py index 72d39fd..e30014c 100644 --- a/GramAddict/core/config.py +++ b/GramAddict/core/config.py @@ -141,7 +141,6 @@ class Config: self.parser.add_argument("--time-delta-session", help="Time delta between sessions", default=None) self.parser.add_argument("--restart-atx-agent", action="store_true", help="Restart atx agent") self.parser.add_argument("--allow-untested-ig-version", action="store_true", help="Allow untested IG version") - self.parser.add_argument("--capture-e2e-dumps", action="store_true", help="Automatically navigate through the app and capture missing XML dumps for the test suite") # Interaction settings self.parser.add_argument("--likes-count", help="Likes count", default="2-3") diff --git a/GramAddict/core/dump_capturer.py b/GramAddict/core/dump_capturer.py deleted file mode 100644 index 874032f..0000000 --- a/GramAddict/core/dump_capturer.py +++ /dev/null @@ -1,105 +0,0 @@ -import os -import time -import logging - - -logger = logging.getLogger(__name__) - -def capture_all(device): - """ - Automated E2E Dump Capturer Sequence. - Navigates through the Instagram UI and securely saves exact XML representations - to satisfy the `e2e_device_dump_injector` test requirements. - - Warning: Requires a logged-in session and active device connection. - """ - logger.info("πŸ“Έ Initiating E2E Dump Capture Sequence!") - - FIX_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "tests", "fixtures") - os.makedirs(FIX_DIR, exist_ok=True) - - def _save_dump(filename, description): - logger.info(f"⏳ Waiting for UI to settle for [{description}]...") - time.sleep(3.5) # ensure animations finish - xml_data = device.dump_hierarchy() - path = os.path.join(FIX_DIR, filename) - with open(path, "w", encoding="utf-8") as f: - f.write(xml_data) - logger.info(f"βœ… Saved ECHTEN DUMP to {filename}") - - print("\n" + "="*50) - print("πŸ€– MANUAL E2E DUMP CAPTURE SEQUENCE") - print("="*50) - print("Please follow the instructions below to capture the required fixtures.") - print("If an IG update changed the layout, you can navigate there naturally.") - print("="*50 + "\n") - - try: - # Pre-condition: Device connected - logger.info("Verifying device connection...") - device.info - - # 1. Comment Sheet - input("\nπŸ‘‰ 1. COMMENT SHEET:\nOpen Instagram, scroll to any post on the HomeFeed, and open the comment section.\nWhen the comment sheet is fully visible, press ENTER to capture...") - _save_dump("comment_sheet.xml", "Post Comment Sheet") - - # 2. Stories Feed - input("\nπŸ‘‰ 2. STORIES FEED:\nGo to the HomeFeed and tap any user's story right at the top.\nWhile the story is playing (video/photo is visible), press ENTER to capture...") - _save_dump("stories_feed_dump.xml", "Active Story Playback") - - # 3. DM Inbox - input("\nπŸ‘‰ 3. DM INBOX:\nGo back to the HomeFeed and tap the message icon in the top right to open your inbox.\nWhen your list of chats is visible, press ENTER to capture...") - _save_dump("dm_inbox_dump.xml", "DM Inbox / Threads List") - - # 4. Profile Scraping & Unfollow List - input("\nπŸ‘‰ 4. OWN PROFILE:\nGo to your OWN profile by tapping your avatar in the bottom right corner.\nWhen your bio and grid are fully visible, press ENTER to capture...") - _save_dump("scraping_profile_dump.xml", "Own Profile Root (User Info)") - - input("\nπŸ‘‰ 4.b FOLLOWING LIST:\nFrom your profile, tap your 'Following' (Abonniert) count to open the list of people you follow.\nWhen the list is fully loaded, press ENTER to capture...") - _save_dump("unfollow_list_dump.xml", "Following List Iteration View") - - # 5. Search Feed - input("\nπŸ‘‰ 5. EXPLORE SEARCH:\nTap the magnifying glass (Explore) tab at the bottom. Then, tap into the top 'Search' bar so your keyboard opens.\nWhen you are in the search state, press ENTER to capture...") - _save_dump("search_feed_dump.xml", "Explore Search Input Focus") - - # 6. Reels Feed - input("\nπŸ‘‰ 6. REELS FEED:\nTap the Reels (Video) tab at the bottom center. Let a video start playing.\nPress ENTER to capture...") - _save_dump("reels_feed_dump.xml", "Reels Video Feed") - - # 7. Notifications - input("\nπŸ‘‰ 7. NOTIFICATIONS (ACTIVITY):\nGo to the HomeFeed and tap the Heart icon in the top right to open notifications.\nPress ENTER to capture...") - _save_dump("notifications_dump.xml", "Activity / Notifications tab") - - # 8. Explore Grid - input("\nπŸ‘‰ 8. EXPLORE GRID:\nTap the magnifying glass (Explore) tab, but do NOT tap the search bar.\nWhen the grid of images/videos is visible, press ENTER to capture...") - _save_dump("explore_feed_dump.xml", "Explore Discovery Grid") - - # 9. Other User's Profile - input("\nπŸ‘‰ 9. ALIEN PROFILE:\nNavigate to ANY OTHER user's profile (e.g. from your Feed or Search).\nWhen their bio and grid are visible, press ENTER to capture...") - _save_dump("user_profile_dump.xml", "Alien Profile Root") - - # 10. Followers List - input("\nπŸ‘‰ 10. FOLLOWERS LIST:\nFrom that profile (or your own), tap the 'Followers' (Abonnenten) count.\nWhen the list of followers is visible, press ENTER to capture...") - _save_dump("followers_list_dump.xml", "Followers List Iteration View") - - # 11. Carousel Post - input("\nπŸ‘‰ 11. CAROUSEL POST:\nScroll your Feed until you see a Carousel (a post with multiple swipable images/videos).\nWhen it is visible, press ENTER to capture...") - _save_dump("carousel_post_dump.xml", "Carousel Post Wrapper") - - # 12. Sponsored Post / Ad - input("\nπŸ‘‰ 12. SPONSORED AD:\nScroll your Feed or Stories until you see a Sponsored / Gesponsert Post with an action button.\nWhen the Ad is visible, press ENTER to capture...") - _save_dump("home_feed_with_ad.xml", "Sponsored Ad Post") - - # 13. Inside DM Chat - input("\nπŸ‘‰ 13. DM CHAT THREAD:\nOpen any message thread in your DM inbox.\nWhen the chat messages and text input field are visible, press ENTER to capture...") - _save_dump("dm_thread_dump.xml", "Direct Message Chat Thread") - - print("\n" + "="*50) - logger.info("πŸŽ‰ Capture Sequence Complete! All 13 E2E dumps have been placed into tests/fixtures/") - print("="*50 + "\n") - - except KeyboardInterrupt: - print("\n") - logger.info("πŸ›‘ Capture Sequence Interrupted by User.") - except Exception as e: - logger.error(f"πŸ’₯ Capture Sequence crashed: {e}", exc_info=True) diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..9b02889 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,129 @@ +# πŸ§ͺ Instagram Bot Testing Standards + +This project follows a strict **Test-Driven Development (TDD)** philosophy. We do not write features blindly; we ground our development in real-world observations and automated verification. + +## πŸ”΄πŸŸ’πŸ”΅ The TDD Workflow (Red-Green-Refactor) + +Every new feature or bugfix should follow this cycle: + +1. **RED**: Start by obtaining a **Real XML Dump** (using the Testing Toolkit) of the target UI state. Write a test that fails against this dump or a `--live` device. +2. **GREEN**: Implement the minimum amount of code (logic in `TelepathicEngine`, `QNavGraph`, etc.) to make the test pass. +3. **REFACTOR**: Clean up the code. Ensure it adheres to our [Best Practices](#-best-practices--no-gos), is well-documented, and doesn't introduce regressions. + +> [!TIP] +> TDD-specific tests, regression fixes, and edge-case hardening should be placed in the `tests/tdd/` directory. + +--- + + +## 1. Mock Testing (Offline Mode) +This is the **default mode** used in CI/CD pipelines and for rapid local development iterations. + +- **Concept**: The `DeviceFacade` is fully mocked. UI states are loaded from static XML fixtures located in `tests/fixtures/`. +- **Primary Benefit**: Extremely fast (<1s per test) and requires no physical device or emulator. +- **Command**: + ```bash + pytest + ``` + +--- + +## 2. Fixture Management (The Toolkit) +To prevent offline tests from validating against outdated Instagram layouts, XML fixtures must be periodically synchronized. Use the **Testing Toolkit** located in `scripts/`. + +### Interactive Guide (Full Sync) +This guide walks you through 13 critical views (Home, Explore, DMs, etc.) and automatically captures the required dumps. +- **Command**: + ```bash + python3 scripts/sync_fixtures.py --config test_config.yml --interactive + ``` + +### Single Fixture Update +If only a specific screen has changed: +- **Command**: + ```bash + python3 scripts/sync_fixtures.py --config test_config.yml --fixture explore_feed_dump.xml + ``` + +--- + +## 3. Live Hardware Testing (Real Device Mode) +Validates that the bot correctly interacts with a real device (emulator or physical phone) by performing actual clicks and swipes. + +- **Concept**: Disables mocks. `pytest` connects via ADB to the active device. +- **Primary Benefit**: Detects UI synchronization issues, animation delays, and ADB connection drops. +- **Command**: + ```bash + pytest --live + ``` + *(Note: Uses the device ID specified in your config or `conftest.py` defaults).* + +--- + +## 4. AI & LLM Validation +Verifies that the bot's "brain" (Telepathic Engine) still understands XML structures and correctly maps elements to actions (e.g., finding the "Like Button"). + +- **Concept**: Sends real prompts to your local LLM server (Ollama/Qwen). +- **Primary Benefit**: Protects against "prompt drift" or performance regressions after model updates. +- **Command**: + ```bash + RUN_LIVE_AI_TESTS=1 pytest tests/integration/test_live_telepathy.py + ``` + +--- + +## 5. E2E Functional Sequences +These tests simulate full automation loops to ensure that different components (Goap, Navigation, SAE) play together correctly. + +- **Concept**: Targeted scenario tests that verify a complete user flow from start to finish. +- **Example Flows**: + - `tests/e2e/test_e2e_explore_feed.py`: Validates the full "Explore -> Analyze -> Interact" loop. + - `tests/e2e/test_e2e_dm_sequence.py`: Validates handling of message threads. +- **Command**: + ```bash + pytest tests/e2e/ + ``` + +--- + +## 6. Cognitive Benchmarking +Measures the "IQ" and latency of your LLM models to ensure they are suitable for autonomous navigation. + +- **Concept**: Runs a series of 13+ UI-parsing scenarios against your configured models and scores them on accuracy and speed. +- **Benefit**: Identifies models that are too slow or "hallucinate" UI coordinates before you let them loose on your real account. +- **Command (Ollama)**: + ```bash + python3 benchmarks/run_competitive_benchmark.py --all-ollama + ``` +- **Command (Existing Config)**: + ```bash + python3 benchmarks/run_competitive_benchmark.py --config test_config.yml + ``` + +--- + +## πŸ›  Troubleshooting +- **Device offline**: Ensure that `adb devices` lists your device and it is authorized. +- **LLM Timeout**: Verify that Ollama is running (`ollama list`) and the required model (e.g., `qwen3.5:latest`) is loaded. +- **Missing Fixture**: If a test fails with `MISSING REAL DUMP`, use the Toolkit (Step 2) to capture the missing screen. +- **Benchmark Failures**: If a model fails benchmarks, it is automatically marked as `is_unsuitable` and should not be used for critical navigation tasks. + +--- + +## πŸ’Ž Best Practices & No-Gos + +To maintain a high-fidelity test suite, all developers must adhere to these standards: + +### βœ… Best Practices +- **Use Golden Fixtures**: Always use real, freshly pulled XML dumps. If the Instagram UI changes, update the fixtures immediately using the Testing Toolkit. +- **Singleton Isolation**: Ensure all core singletons (`TelepathicEngine`, `GoalExecutor`) are reset between tests in `conftest.py`. +- **Hermetic Tests**: Each test must be independent. Ensure on-disk caches (JSON files) are wiped before each run. +- **Layered Validation**: Start with fast mock tests for logic, then verify with `--live` hardware tests for physical interaction. +- **Relative Pathing**: Use `os.path.join` relative to `__file__` for all fixture loading. + +### ❌ No-Gos +- **"Lying" Mocks**: Never create hand-written or "guessed" XML structures. If you don't have a dump, pull a real one. +- **Hardcoded Absolute Paths**: Never use paths like `/Users/name/...`. These break CI and other developers' environments. +- **State Leakage**: Never rely on the side effects of a previous test. If a test fails, it should not cause subsequent tests to fail. +- **Implicit Timing in Mocks**: Do not use `time.sleep()` for UI waiting in offline tests. Rely on the `VirtualClock` or state-based assertions. +- **Mocking Navigation Logic**: In E2E tests, do not mock the internal decision-making of the `TelepathicEngine` or `GrowthBrain`. Force them to process real (fixture) data. diff --git a/scripts/sync_fixtures.py b/scripts/sync_fixtures.py new file mode 100755 index 0000000..362a2c3 --- /dev/null +++ b/scripts/sync_fixtures.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +import os +import sys +import argparse +import time +import logging + +# Ensure GramAddict is in PYTHONPATH if script is run directly +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from GramAddict.core.device_facade import create_device +from GramAddict.core.config import Config + +# Setup a clean logger for the toolkit +logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s | %(message)s', datefmt='%H:%M:%S') +logger = logging.getLogger("TestingToolkit") + +def _save_dump(device, fixture_dir, filename, description): + logger.info(f"⏳ Waiting for UI to settle for [{description}]...") + time.sleep(3.5) # ensure animations finish + xml_data = device.dump_hierarchy() + + if not xml_data or len(xml_data) < 100: + logger.warning(f"⚠️ Received empty or exceptionally small XML dump for {filename}. Is the app open?") + + path = os.path.join(fixture_dir, filename) + with open(path, "w", encoding="utf-8") as f: + f.write(xml_data) + logger.info(f"βœ… Saved REAL DUMP to {filename} ({len(xml_data)} bytes)") + +def run_interactive_guide(device, fixture_dir): + print("\n" + "="*60) + print("πŸ€– AUTOMATED E2E DUMP CAPTURE GUIDE") + print("="*60) + print("This guide will help you refresh the golden fixtures for the E2E suite.") + print("Please follow the instructions on your phone/emulator.") + print("="*60 + "\n") + + steps = [ + ("comment_sheet.xml", "Post Comment Sheet", "Open any post and open its comment section."), + ("stories_feed_dump.xml", "Active Story Playback", "Tap any user's story ring on the HomeFeed."), + ("dm_inbox_dump.xml", "DM Inbox / Threads List", "Go to your DM inbox list."), + ("scraping_profile_dump.xml", "Own Profile Root", "Go to your OWN profile tab."), + ("unfollow_list_dump.xml", "Following List", "On your profile, tap 'Following' to open the list."), + ("search_feed_dump.xml", "Explore Search Focus", "Tap Explore, then tap into the Search bar."), + ("reels_feed_dump.xml", "Reels Video Feed", "Go to the Reels tab and let a video play."), + ("notifications_dump.xml", "Activity Feed", "Tap the Heart/Notifications icon."), + ("explore_feed_dump.xml", "Explore Grid", "Tap the Explore tab (just the grid visible)."), + ("user_profile_dump.xml", "Alien Profile Root", "Go to ANY OTHER user's profile."), + ("followers_list_dump.xml", "Followers List", "On that user's profile, tap 'Followers'."), + ("carousel_post_dump.xml", "Carousel Post", "Find a post with multiple images on your feed."), + ("home_feed_with_ad.xml", "Sponsored Ad", "Scroll until you see a 'Sponsored' post."), + ("dm_thread_dump.xml", "DM Chat Thread", "Open any specific chat conversation.") + ] + + for i, (fname, desc, instr) in enumerate(steps, 1): + try: + print(f"\nπŸ‘‰ {i}/{len(steps)}. {desc.upper()}:") + print(f" {instr}") + input(" Press ENTER when ready to capture...") + _save_dump(device, fixture_dir, fname, desc) + except KeyboardInterrupt: + print("\nπŸ›‘ Guide interrupted.") + return + + print("\n" + "="*60) + logger.info("πŸŽ‰ Capture Sequence Complete! All fixtures have been updated.") + print("="*60 + "\n") + +def main(): + parser = argparse.ArgumentParser(description="Instagram Bot Testing Toolkit - Fixture Management") + parser.add_argument("--config", help="Path to your bot's .yml config (to auto-extract device ID)") + parser.add_argument("--device", help="Manual ADB device ID (e.g., emulator-5554)") + parser.add_argument("--fixture", help="Name of a single fixture to sync (e.g., explore_feed_dump.xml)") + parser.add_argument("--interactive", action="store_true", help="Launch the full interactive capture guide") + args = parser.parse_args() + + device_id = args.device + + # Try to extract device from config if provided + if args.config: + try: + # We use a dummy Config instance to parse the file + bot_config = Config(first_run=True, config=args.config) + bot_config.parse_args() + device_id = bot_config.device_id or device_id + logger.info(f"Loaded device ID from config: {device_id}") + except Exception as e: + logger.warning(f"Could not read config file {args.config}: {e}") + + if not device_id: + logger.error("No device ID provided! Use --device or --config to specify which device to connect to.") + sys.exit(1) + + fixture_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tests", "fixtures") + os.makedirs(fixture_dir, exist_ok=True) + + print(f"Connecting to {device_id}...") + try: + device = create_device(device_id, "com.instagram.android") + # Quick connectivity check + device.get_info() + except Exception as e: + logger.error(f"Failed to connect to device: {e}") + sys.exit(1) + + if args.interactive: + run_interactive_guide(device, fixture_dir) + elif args.fixture: + fname = args.fixture + if not fname.endswith(".xml"): fname += ".xml" + _save_dump(device, fixture_dir, fname, f"Single Fixture: {fname}") + else: + logger.info("Nothing to do. Use --interactive or --fixture .") + parser.print_help() + +if __name__ == "__main__": + main() diff --git a/test_config.yml b/test_config.yml index 9090147..f386119 100644 --- a/test_config.yml +++ b/test_config.yml @@ -54,7 +54,7 @@ limits: max_comments_per_day: 40 # ── Infrastructure (Nur fΓΌr Entwickler) ── -device: 192.168.1.206:41441 +device: 192.168.1.206:45003 app-id: com.instagram.android ai-model: qwen3.5:latest ai-model-url: http://localhost:11434/api/generate diff --git a/tests/conftest.py b/tests/conftest.py index 962f714..1f50075 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,12 @@ import pytest import logging import os from unittest.mock import MagicMock + +def pytest_addoption(parser): + parser.addoption( + "--live", action="store_true", default=False, help="run tests against a live ADB device (disable DeviceFacade mocks)" + ) + MagicMock.app_id = "com.instagram.android" MagicMock._get_current_app = MagicMock(return_value="com.instagram.android") @@ -44,7 +50,10 @@ def mock_logger(): return logging.getLogger("test") @pytest.fixture -def device(): +def device(request): + if request.config.getoption("--live"): + from GramAddict.core.device_facade import create_device + return create_device("emulator-5554", "com.instagram.android") return create_mock_device() @pytest.fixture(autouse=True) @@ -68,7 +77,10 @@ def reset_singletons(): yield @pytest.fixture(autouse=True) -def telepathic_mock(monkeypatch): +def telepathic_mock(monkeypatch, request): + if request.config.getoption("--live"): + # TelepathicEngine is a singleton, allow it to run natively + return None import GramAddict.core.telepathic_engine engine = create_mock_telepathic_engine() monkeypatch.setattr(GramAddict.core.telepathic_engine.TelepathicEngine, "get_instance", lambda: engine) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index ef5be62..75c6938 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -17,12 +17,15 @@ mock_qdrant.get_collection.return_value = mock_collection sys.modules["qdrant_client"].QdrantClient = MagicMock(return_value=mock_qdrant) @pytest.fixture -def e2e_device_dump_injector(): +def e2e_device_dump_injector(request): """ Provides a factory to mock device.dump_hierarchy using real XML files. Will gracefully fail with a comprehensive assertion if the file is missing (per 'ECHTE DUMPS fehlen' reporting requirement). """ + if request.config.getoption("--live"): + return lambda *args, **kwargs: None + def _inject_dump(device_mock, xml_filename): fix_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures") xml_path = os.path.join(fix_dir, xml_filename) @@ -51,13 +54,16 @@ class VirtualClock: clock = VirtualClock() @pytest.fixture -def dynamic_e2e_dump_injector(monkeypatch): +def dynamic_e2e_dump_injector(monkeypatch, request): """ State-Machine Injector: Replaces dump_hierarchy dynamically when transitions occur. Validates that the Telepathic Engine's pathfinding truly worked. It now inherently simulates UI animation delays. If a dump is requested LESS than 1.5 virtual seconds after a transition, it returns a garbage animating UI. """ + if request.config.getoption("--live"): + return lambda *args, **kwargs: None + def _inject(device_mock, state_map, initial_xml): from GramAddict.core.q_nav_graph import QNavGraph @@ -123,12 +129,15 @@ def dynamic_e2e_dump_injector(monkeypatch): return _inject @pytest.fixture(autouse=True) -def mock_all_delays(monkeypatch): +def mock_all_delays(monkeypatch, request): """ Replaces all humanized hardware delays specifically for the E2E test suite with a Virtual Clock. Ensures loops evaluate instantly but preserves chronological dependency for our Animation Simulator. """ + if request.config.getoption("--live"): + return + global clock clock.time = 0.0 # reset for test clock.animation_target_time = 0.0 diff --git a/tests/integration/test_live_telepathy.py b/tests/integration/test_live_telepathy.py new file mode 100644 index 0000000..87ab0b1 --- /dev/null +++ b/tests/integration/test_live_telepathy.py @@ -0,0 +1,48 @@ +import os +import pytest +from unittest.mock import MagicMock + +from GramAddict.core.telepathic_engine import TelepathicEngine +from GramAddict.core.device_facade import DeviceFacade + +@pytest.mark.skipif(not os.environ.get("RUN_LIVE_AI_TESTS"), reason="Requires a running local LLM/Ollama instance and RUN_LIVE_AI_TESTS=1") +def test_real_llm_pathfinding_on_golden_fixture(): + """ + Validates that the REAL telepathic engine (hitting the real LLM endpoint) + can successfully parse a real IG XML dump and locate standard elements + without hallucinating or crashing. + """ + # 1. Load a real XML dump from our golden fixtures + fixture_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures") + xml_path = os.path.join(fixture_dir, "explore_feed_dump.xml") + + assert os.path.exists(xml_path), "Golden fixture explore_feed_dump.xml is missing. Make sure sync_fixtures.py was run." + + with open(xml_path, "r", encoding="utf-8") as f: + xml_data = f.read() + + # 2. Setup a fresh real engine + engine = TelepathicEngine() + + # Mute the actual screencap logic, but provide valid display bounds + device = MagicMock(spec=DeviceFacade) + device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400} + + # Mock screenshot to prevent hitting the adb shell during this specific offline text-parsing test + device.screenshot = MagicMock() + + # 3. Fire the intent against the real LLM endpoint. + # We force min_confidence=1.1 so it ALWAYS hits the LLM Vision Cortex Fallback + # and bypasses the local vector DB cache (to ensure we test the prompt & JSON extraction). + result = engine.find_best_node(xml_data, "tap reels tab", min_confidence=1.1, device=device) + + # 4. Assert it actually found something sane instead of hallucinating + assert result is not None, "Real LLM failed to understand the XML and return a valid action json." + assert "x" in result and "y" in result, "Coordinates missing from VLM payload" + + # The Reels tab on standard IG UI is in the bottom navigation bar + # usually around y=2200-2400 and roughly x=540 + assert result["y"] > 2000, f"Expected reels tab to be at the bottom screen, but got y={result['y']}" + assert 400 < result["x"] < 700, f"Expected reels tab to be in bottom middle, but got x={result['x']}" + + print(f"SUCCESS: LLM detected reels tab correctly at x={result['x']}, y={result['y']}")