test(e2e): add plugin chain integration tests for hostile environments

THE ROOT CAUSE OF LYING TESTS:
81 E2E tests existed but ZERO tested the full plugin chain (execute_all)
against non-Instagram environments. Each plugin was tested in isolation,
but the integration between perception (SAE) and action (obstacle_guard)
was a complete blind spot.

NEW TESTS:
- test_chain_terminates_on_permission_dialog: Proves the full plugin chain
  terminates at obstacle_guard when a system permission dialog is detected.
  No downstream interaction plugins (resonance, like, follow) may fire.
- test_chain_terminates_on_foreign_app: Same for Chrome/browser takeover.
- test_chain_passes_through_on_normal_instagram: Sanity check that the
  chain runs normally on valid Instagram feeds.

These tests would have caught the 23:01 stuck-loop bug BEFORE it shipped.
This commit is contained in:
2026-04-29 23:13:54 +02:00
parent 47f94b699c
commit db226ed7c2

View File

@@ -0,0 +1,381 @@
"""
🔴 E2E Plugin Chain Integration Tests
========================================
These tests exercise the FULL plugin chain (PluginRegistry.execute_all) against
hostile environments that the bot encounters in production:
1. System permission dialogs (Android PermissionController)
2. Foreign app overlays (browser, wrong app in foreground)
3. Reel creation flows (camera, audio recording prompts)
WHY THESE TESTS EXIST:
Production run 2026-04-29 23:01 revealed that:
- The SAE correctly detects OBSTACLE_SYSTEM for permission dialogs
- The obstacle_guard plugin IGNORED OBSTACLE_SYSTEM (only handled OBSTACLE_MODAL)
- All downstream plugins (resonance, dwell, like, follow) fired against a
non-Instagram permission dialog
- The VLM hallucinated box [4] (icon) as "post username" and "media content"
- The bot got completely stuck in an infinite loop
NONE of the existing 81 E2E tests caught this because:
- They test each plugin in ISOLATION (not as a chain)
- They NEVER call PluginRegistry.execute_all() against hostile environments
- The conftest registers all plugins but no test actually exercises the chain
These tests fix that gap permanently.
"""
import argparse
import os
import pytest
from GramAddict.core.behaviors import BehaviorContext, BehaviorResult, PluginRegistry
from GramAddict.core.situational_awareness import SituationType
# ═══════════════════════════════════════════════════════
# Hostile XML Fixtures (inline for speed, no file I/O)
# ═══════════════════════════════════════════════════════
PERMISSION_DIALOG_XML = """<?xml version="1.0" encoding="utf-8"?>
<hierarchy rotation="0">
<node class="android.widget.FrameLayout" package="com.android.permissioncontroller"
bounds="[0,0][1080,2400]">
<node resource-id="com.android.permissioncontroller:id/grant_dialog"
class="android.widget.LinearLayout" package="com.android.permissioncontroller"
clickable="false" bounds="[100,800][980,1600]">
<node text="Allow Instagram to record audio?"
class="android.widget.TextView"
resource-id="com.android.permissioncontroller:id/permission_message" />
<node text="While using the app"
class="android.widget.Button" clickable="true"
resource-id="com.android.permissioncontroller:id/permission_allow_foreground_only_button"
bounds="[100,1000][980,1100]" />
<node text="Only this time"
class="android.widget.Button" clickable="true"
resource-id="com.android.permissioncontroller:id/permission_allow_one_time_button"
bounds="[100,1120][980,1220]" />
<node text="Don't allow"
class="android.widget.Button" clickable="true"
resource-id="com.android.permissioncontroller:id/permission_deny_button"
bounds="[100,1240][980,1340]" />
<node resource-id="com.android.permissioncontroller:id/permission_icon"
class="android.widget.ImageView"
content-desc=""
bounds="[440,850][640,950]" />
</node>
</node>
</hierarchy>
"""
FOREIGN_APP_CHROME_XML = """<?xml version="1.0" encoding="utf-8"?>
<hierarchy rotation="0">
<node class="android.widget.FrameLayout" package="com.android.chrome"
bounds="[0,0][1080,2400]">
<node text="https://www.example.com"
class="android.widget.EditText"
resource-id="com.android.chrome:id/url_bar" />
<node text="Example Domain"
class="android.widget.TextView" />
</node>
</hierarchy>
"""
NORMAL_INSTAGRAM_XML = """<?xml version="1.0" encoding="utf-8"?>
<hierarchy rotation="0">
<node class="android.widget.FrameLayout" package="com.instagram.android"
bounds="[0,0][1080,2400]">
<node resource-id="com.instagram.android:id/row_feed_button_like"
class="android.widget.ImageView" clickable="true"
content-desc="Like" bounds="[30,1500][120,1590]" />
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name"
class="android.widget.TextView" text="photographer_jane"
bounds="[160,300][600,340]" />
</node>
</hierarchy>
"""
# ═══════════════════════════════════════════════════════
# Realistic Device Stub
# ═══════════════════════════════════════════════════════
class ChainTestDevice:
"""Deterministic device for chain testing. Returns XML sequence."""
def __init__(self, xml_sequence):
self._xml_sequence = list(xml_sequence)
self._dump_index = 0
self.pressed_keys = []
self.clicks = []
self.app_id = "com.instagram.android"
class _V2:
info = {"screenOn": True, "sdkInt": 30, "displaySizeDpX": 400, "displayWidth": 1080}
settings = {}
touch = type("T", (), {"down": lambda s, x, y: None, "up": lambda s, x, y: None})()
def dump_hierarchy(self, compressed=False):
return ""
def app_current(self):
return {"package": "com.instagram.android"}
def screenshot(self):
return None
def shell(self, cmd):
pass
self.deviceV2 = _V2()
def dump_hierarchy(self):
if self._dump_index < len(self._xml_sequence):
xml = self._xml_sequence[self._dump_index]
self._dump_index += 1
return xml
return self._xml_sequence[-1]
def press(self, key):
self.pressed_keys.append(key)
def click(self, x, y):
self.clicks.append((x, y))
def swipe(self, sx, sy, ex, ey, **kwargs):
pass
def app_start(self, pkg, use_monkey=False):
pass
def app_stop(self, pkg):
pass
def unlock(self):
pass
def get_screenshot_b64(self):
return None
class ChainTestConfig:
def __init__(self):
self.args = argparse.Namespace(
username="testuser",
device="emulator-5554",
app_id="com.instagram.android",
debug=True,
interact_percentage=100,
visual_vibe_check_percentage=0,
persona_interests="travel, nature",
target_audience="travel, nature",
speed_multiplier=1.0,
likes_count="1",
likes_percentage=100,
follow_percentage=100,
comment_percentage=0,
carousel_percentage=0,
carousel_count="1",
stories_count="0",
stories_percentage=0,
disable_ai_messaging=True,
scrape_profiles=False,
dry_run_comments=True,
)
self.config = {
"plugins": {
"likes": {"count": "1", "percentage": 100},
"follow": {"percentage": 100},
"comment": {"percentage": 0, "dry_run": True},
"stories": {"count": "0", "percentage": 0},
"resonance_evaluator": {"visual_vibe_check_percentage": 0},
"carousel_browsing": {"percentage": 0, "count": "1"},
}
}
self.username = "testuser"
def get_plugin_config(self, name):
return self.config.get("plugins", {}).get(name, {})
class ChainTestSession:
def __init__(self):
self.totalLikes = 0
self.totalFollowed = 0
self.totalComments = 0
self.totalGetProfile = 0
self.totalReposted = 0
self.totalStoriesWatched = 0
self.totalScraped = 0
self.job_target = "test_chain"
self.totalInteractions = {}
def check_limit(self, *args, **kwargs):
return False
# ═══════════════════════════════════════════════════════
# THE ACTUAL CHAIN INTEGRATION TESTS
# ═══════════════════════════════════════════════════════
class TestPluginChainOnSystemDialog:
"""
THE BIG LIE: Before this test existed, no E2E test exercised the full
plugin chain against a system permission dialog. The SAE perception test
verified detection, but obstacle_guard silently ignored it.
This test proves the chain TERMINATES on hostile environments.
"""
def test_chain_terminates_on_permission_dialog(self, monkeypatch, setup_e2e_plugin_registry):
"""
When PluginRegistry.execute_all() runs against a permission dialog,
the obstacle_guard (priority 95) MUST fire and terminate the chain.
No downstream plugin (resonance, like, follow) may execute.
"""
device = ChainTestDevice([PERMISSION_DIALOG_XML, NORMAL_INSTAGRAM_XML])
registry = setup_e2e_plugin_registry
# Monkeypatch SAE to return OBSTACLE_SYSTEM
class FakeSAE:
@classmethod
def get_instance(cls, device=None):
return cls()
def perceive(self, xml):
if "permissioncontroller" in xml:
return SituationType.OBSTACLE_SYSTEM
return SituationType.NORMAL
monkeypatch.setattr(
"GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine",
FakeSAE,
)
ctx = BehaviorContext(
device=device,
configs=ChainTestConfig(),
session_state=ChainTestSession(),
cognitive_stack={},
shared_state={},
context_xml=PERMISSION_DIALOG_XML,
)
results = registry.execute_all(ctx)
# The chain MUST have terminated before any INTERACTION plugins fired
assert len(results) >= 1, "No plugin executed at all"
# Find the obstacle_guard result (should be the one that executed + should_skip)
guard_fired = any(r.executed and r.should_skip for r in results)
assert guard_fired, (
"obstacle_guard did not fire — no result has executed=True + should_skip=True. "
"This means the permission dialog was ignored and interaction plugins will run!"
)
# Critical: NO interaction plugin may have performed actual interactions
total_interactions = sum(r.interactions for r in results)
assert total_interactions == 0, (
f"{total_interactions} interaction(s) were performed against a permission dialog!"
)
# The device must have pressed BACK to dismiss the dialog
assert "back" in device.pressed_keys, (
"obstacle_guard did not press BACK to dismiss the system dialog"
)
def test_chain_terminates_on_foreign_app(self, monkeypatch, setup_e2e_plugin_registry):
"""
When Chrome is in the foreground, the chain must terminate at obstacle_guard.
"""
device = ChainTestDevice([FOREIGN_APP_CHROME_XML, NORMAL_INSTAGRAM_XML])
registry = setup_e2e_plugin_registry
class FakeSAE:
@classmethod
def get_instance(cls, device=None):
return cls()
def perceive(self, xml):
if "chrome" in xml:
return SituationType.OBSTACLE_FOREIGN_APP
return SituationType.NORMAL
monkeypatch.setattr(
"GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine",
FakeSAE,
)
ctx = BehaviorContext(
device=device,
configs=ChainTestConfig(),
session_state=ChainTestSession(),
cognitive_stack={},
shared_state={},
context_xml=FOREIGN_APP_CHROME_XML,
)
results = registry.execute_all(ctx)
# obstacle_guard must have fired and terminated the chain
guard_fired = any(r.executed and r.should_skip for r in results)
assert guard_fired, (
"obstacle_guard did not fire on foreign app. "
"Downstream plugins are firing against a Chrome browser window!"
)
total_interactions = sum(r.interactions for r in results)
assert total_interactions == 0, (
f"{total_interactions} interaction(s) were performed against Chrome!"
)
assert "back" in device.pressed_keys
class TestPluginChainOnNormalFeed:
"""
Sanity check: On a NORMAL Instagram feed, the chain must run normally.
The obstacle_guard must NOT fire (executed=False), allowing downstream plugins.
"""
def test_chain_passes_through_on_normal_instagram(self, monkeypatch, setup_e2e_plugin_registry):
device = ChainTestDevice([NORMAL_INSTAGRAM_XML])
registry = setup_e2e_plugin_registry
class FakeSAE:
@classmethod
def get_instance(cls, device=None):
return cls()
def perceive(self, xml):
return SituationType.NORMAL
monkeypatch.setattr(
"GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine",
FakeSAE,
)
ctx = BehaviorContext(
device=device,
configs=ChainTestConfig(),
session_state=ChainTestSession(),
cognitive_stack={},
shared_state={},
context_xml=NORMAL_INSTAGRAM_XML,
)
results = registry.execute_all(ctx)
# Multiple plugins should have run (obstacle_guard passed through, then data extraction, etc.)
assert len(results) > 1, (
f"Only {len(results)} plugin(s) executed on a normal Instagram feed. "
f"The obstacle_guard may be incorrectly blocking the chain."
)
# obstacle_guard must have executed=False (no obstacle detected)
obstacle_results = [r for r in results if not r.executed or "back" not in device.pressed_keys]
assert "back" not in device.pressed_keys, (
"obstacle_guard pressed BACK on a normal Instagram screen — false positive!"
)