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:
381
tests/e2e/test_plugin_chain_hostile_env.py
Normal file
381
tests/e2e/test_plugin_chain_hostile_env.py
Normal 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!"
|
||||
)
|
||||
Reference in New Issue
Block a user