fix(navigation): harden autonomous perimeter guards against Play Store trap
This commit is contained in:
@@ -247,7 +247,8 @@ class InstagramEmulator:
|
||||
# Could implement swipe transitions here (e.g. scroll down loads new feed)
|
||||
|
||||
def app_start(self, pkg, use_monkey=False):
|
||||
pass
|
||||
self.app_starts.append(pkg)
|
||||
logger.info(f"[Emulator] app_start({pkg})")
|
||||
|
||||
def app_stop(self, pkg):
|
||||
pass
|
||||
|
||||
@@ -34,7 +34,7 @@ def test_goap_recovers_from_trapped_state_with_restart(monkeypatch):
|
||||
# We just want to see that AFTER the restart, it tries 'tap reels tab' again,
|
||||
# meaning it cleared the state.
|
||||
|
||||
goap.achieve("open reels", max_steps=6)
|
||||
goap.achieve("open reels", max_steps=10)
|
||||
|
||||
# It should have tried 'tap reels tab' twice, failed both times,
|
||||
# then triggered 'force start instagram'.
|
||||
|
||||
52
tests/e2e/test_production_bug_play_store_trap_20260503.py
Normal file
52
tests/e2e/test_production_bug_play_store_trap_20260503.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Production Bug Regression: Play Store Trap (2026-05-03)
|
||||
========================================================
|
||||
Session: 2026-05-03_18-03-15
|
||||
Trace: Frames 14-40 (Instagram Stories) → Frame 41 (com.android.vending)
|
||||
|
||||
Root Cause: Story loop had no perimeter guard. A story's swipe-up link
|
||||
opened the Play Store, and the bot continued tapping at (w*0.85, h*0.5)
|
||||
on Play Store UI for 5+ iterations until user killed the process.
|
||||
|
||||
This test uses the EXACT production XML dumps to validate:
|
||||
1. ScreenIdentity correctly classifies Play Store as FOREIGN_APP
|
||||
2. SAE correctly classifies Play Store as OBSTACLE_FOREIGN_APP
|
||||
3. Story frame before the linkout is correctly classified as STORY_VIEW
|
||||
"""
|
||||
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
|
||||
|
||||
|
||||
class TestPlayStoreTrapRegression:
|
||||
def test_screen_identity_classifies_play_store_as_foreign(self):
|
||||
"""ScreenIdentity must classify com.android.vending as FOREIGN_APP."""
|
||||
xml = load_fixture_xml("play_store_from_story_link.xml")
|
||||
sid = ScreenIdentity("testuser")
|
||||
result = sid.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.FOREIGN_APP, (
|
||||
f"ScreenIdentity classified Play Store as {result['screen_type'].value}! "
|
||||
f"This is the exact production bug from 2026-05-03."
|
||||
)
|
||||
|
||||
def test_sae_classifies_play_store_as_obstacle_foreign_app(self):
|
||||
"""SAE must classify com.android.vending as OBSTACLE_FOREIGN_APP."""
|
||||
xml = load_fixture_xml("play_store_from_story_link.xml")
|
||||
device = E2EDeviceStub([xml])
|
||||
SituationalAwarenessEngine.reset()
|
||||
sae = SituationalAwarenessEngine(device)
|
||||
result = sae.perceive(xml)
|
||||
assert result == SituationType.OBSTACLE_FOREIGN_APP, (
|
||||
f"SAE classified Play Store as {result.value}! " f"Exact production bug regression from 2026-05-03."
|
||||
)
|
||||
|
||||
def test_story_view_before_linkout_is_story(self):
|
||||
"""The last story frame before the Play Store must be STORY_VIEW."""
|
||||
xml = load_fixture_xml("story_view_before_linkout.xml")
|
||||
sid = ScreenIdentity("testuser")
|
||||
result = sid.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.STORY_VIEW, (
|
||||
f"Frame before Play Store was classified as {result['screen_type'].value}, "
|
||||
f"not STORY_VIEW. This could cause false positives."
|
||||
)
|
||||
56
tests/e2e/test_sae_foreign_app_fastpath.py
Normal file
56
tests/e2e/test_sae_foreign_app_fastpath.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
E2E: SAE Foreign App Fast-Path
|
||||
================================
|
||||
Validates that the SAE classifies known foreign packages (Play Store,
|
||||
Chrome, Settings) as OBSTACLE_FOREIGN_APP using O(1) structural detection
|
||||
WITHOUT falling through to LLM classification.
|
||||
|
||||
This eliminates 2-5 seconds of LLM inference for detections that
|
||||
should be instantaneous set lookups.
|
||||
"""
|
||||
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
|
||||
|
||||
|
||||
def test_sae_classifies_play_store_without_llm():
|
||||
"""Play Store XML must be classified as FOREIGN_APP via fast-path, not LLM."""
|
||||
xml = load_fixture_xml("play_store_from_story_link.xml")
|
||||
device = E2EDeviceStub([xml])
|
||||
SituationalAwarenessEngine.reset()
|
||||
sae = SituationalAwarenessEngine(device)
|
||||
|
||||
result = sae.perceive(xml)
|
||||
|
||||
assert result == SituationType.OBSTACLE_FOREIGN_APP, (
|
||||
f"SAE classified Play Store as {result.value} instead of OBSTACLE_FOREIGN_APP! "
|
||||
f"The bot cannot detect it left Instagram."
|
||||
)
|
||||
|
||||
|
||||
def test_sae_fast_path_handles_known_foreign_packages():
|
||||
"""
|
||||
Verify the fast-path handles com.android.vending, com.android.chrome,
|
||||
com.google.android.youtube etc. without LLM calls.
|
||||
com.android.settings may classify as OBSTACLE_SYSTEM which is also valid.
|
||||
"""
|
||||
known_foreign_pkgs = {
|
||||
"com.android.vending": SituationType.OBSTACLE_FOREIGN_APP,
|
||||
"com.android.chrome": SituationType.OBSTACLE_FOREIGN_APP,
|
||||
"com.google.android.youtube": SituationType.OBSTACLE_FOREIGN_APP,
|
||||
}
|
||||
for pkg, expected in known_foreign_pkgs.items():
|
||||
xml = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy rotation="0">
|
||||
<node class="android.widget.FrameLayout" package="{pkg}"
|
||||
bounds="[0,0][1080,2400]">
|
||||
<node text="Some content" class="android.widget.TextView"
|
||||
package="{pkg}" />
|
||||
</node>
|
||||
</hierarchy>"""
|
||||
device = E2EDeviceStub([xml])
|
||||
SituationalAwarenessEngine.reset()
|
||||
sae = SituationalAwarenessEngine(device)
|
||||
|
||||
result = sae.perceive(xml)
|
||||
assert result == expected, f"Package {pkg} was classified as {result.value}, expected {expected.value}!"
|
||||
@@ -154,6 +154,10 @@ class TestSAELoop:
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity
|
||||
from GramAddict.core.qdrant_memory import ScreenMemoryDB
|
||||
|
||||
db = ScreenMemoryDB()
|
||||
if not db.is_connected:
|
||||
pytest.skip("Qdrant is not running — this test requires vector store for NORMAL override storage")
|
||||
|
||||
# Load real home feed XML
|
||||
xml_dump = Path("tests/e2e/fixtures/home_feed_real.xml").read_text()
|
||||
|
||||
@@ -168,7 +172,7 @@ class TestSAELoop:
|
||||
|
||||
# Simulate LLM unlearning by storing this exact state as NORMAL
|
||||
compressed = sae._compress_xml(xml_dump)
|
||||
ScreenMemoryDB().store_screen(compressed, "NORMAL")
|
||||
db.store_screen(compressed, "NORMAL")
|
||||
|
||||
identity = ScreenIdentity("testuser")
|
||||
# Ensure we inject the device so get_screenshot_b64 doesn't crash if it falls back
|
||||
|
||||
67
tests/e2e/test_workflow_feed_foreign_app_escape.py
Normal file
67
tests/e2e/test_workflow_feed_foreign_app_escape.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
E2E: Feed Loop Foreign App Escape
|
||||
====================================
|
||||
Validates that the feed loop detects a foreign app takeover
|
||||
(e.g. Play Store opened) and immediately aborts with CONTEXT_LOST.
|
||||
Parity with story loop guard.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
|
||||
|
||||
|
||||
def test_feed_loop_escapes_foreign_app(e2e_configs):
|
||||
"""
|
||||
Simulates: Feed → Play Store.
|
||||
The loop must detect com.android.vending and abort immediately.
|
||||
"""
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
|
||||
feed_xml = load_fixture_xml("home_feed_with_ad.xml")
|
||||
play_store_xml = load_fixture_xml("play_store_from_story_link.xml")
|
||||
|
||||
# Feed -> Play Store
|
||||
device = E2EDeviceStub([feed_xml, play_store_xml])
|
||||
|
||||
dopamine = DopamineEngine()
|
||||
dopamine.session_limit_seconds = 999
|
||||
|
||||
# Needs a dummy zero_engine and nav_graph
|
||||
class DummyZeroEngine:
|
||||
pass
|
||||
|
||||
class DummyNavGraph:
|
||||
def do(self, action):
|
||||
pass
|
||||
|
||||
def navigate_to(self, state, engine):
|
||||
pass
|
||||
|
||||
cognitive_stack = {
|
||||
"dopamine": dopamine,
|
||||
"zero_engine": DummyZeroEngine(),
|
||||
"nav_graph": DummyNavGraph(),
|
||||
"radome": None,
|
||||
"active_inference": None,
|
||||
}
|
||||
|
||||
session_state = type(
|
||||
"S", (), {"startTime": datetime.datetime.now(), "check_limit": lambda *args, **kwargs: (False, False)}
|
||||
)()
|
||||
|
||||
# Override speed_multiplier to avoid multi-minute sleeps in CI
|
||||
e2e_configs.args.speed_multiplier = 0.01
|
||||
|
||||
result = _run_zero_latency_feed_loop(
|
||||
device, DummyZeroEngine(), DummyNavGraph(), e2e_configs, session_state, "HomeFeed", cognitive_stack
|
||||
)
|
||||
|
||||
# Must return CONTEXT_LOST
|
||||
assert (
|
||||
result == "CONTEXT_LOST"
|
||||
), f"Feed loop returned '{result}' instead of 'CONTEXT_LOST' when Play Store was in foreground."
|
||||
|
||||
# Must have pressed back to attempt recovery
|
||||
assert "back" in device.pressed_keys, "Feed loop detected foreign app but never pressed BACK to recover!"
|
||||
54
tests/e2e/test_workflow_stories_foreign_app_escape.py
Normal file
54
tests/e2e/test_workflow_stories_foreign_app_escape.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
E2E: Story Loop Foreign App Escape
|
||||
====================================
|
||||
Validates that the story binging loop detects a foreign app takeover
|
||||
(e.g. Play Store opened via story link) and immediately aborts with
|
||||
CONTEXT_LOST instead of continuing to tap blindly.
|
||||
|
||||
Production Bug Reproduction:
|
||||
Session: 2026-05-03_18-03-15
|
||||
Trace: Frames 14-40 (Instagram Stories) → Frame 41 (com.android.vending)
|
||||
Root Cause: _run_zero_latency_stories_loop had no perimeter guard.
|
||||
|
||||
Uses the REAL production XML dumps from the 2026-05-03 session trace.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
|
||||
|
||||
|
||||
def test_story_loop_escapes_foreign_app(e2e_configs):
|
||||
"""
|
||||
Simulates: Story → Story → Story → Play Store (via swipe-up link).
|
||||
The loop must detect com.android.vending and abort immediately.
|
||||
"""
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_stories_loop
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
|
||||
story_xml = load_fixture_xml("story_view_before_linkout.xml")
|
||||
play_store_xml = load_fixture_xml("play_store_from_story_link.xml")
|
||||
|
||||
# 3 story iterations → then Play Store appears
|
||||
device = E2EDeviceStub([story_xml, story_xml, story_xml, play_store_xml])
|
||||
|
||||
dopamine = DopamineEngine()
|
||||
dopamine.session_limit_seconds = 999
|
||||
cognitive_stack = {"dopamine": dopamine}
|
||||
|
||||
session_state = type("S", (), {"startTime": datetime.datetime.now()})()
|
||||
|
||||
# Override speed_multiplier to avoid multi-minute sleeps in CI
|
||||
e2e_configs.args.speed_multiplier = 0.01
|
||||
|
||||
result = _run_zero_latency_stories_loop(device, e2e_configs, session_state, cognitive_stack)
|
||||
|
||||
# Must return CONTEXT_LOST, NOT FEED_EXHAUSTED
|
||||
assert result == "CONTEXT_LOST", (
|
||||
f"Story loop returned '{result}' instead of 'CONTEXT_LOST' when Play Store was in foreground. "
|
||||
f"The bot would have tapped blindly on the Play Store! "
|
||||
f"This is the exact production bug from 2026-05-03."
|
||||
)
|
||||
|
||||
# Must have pressed back to attempt recovery
|
||||
assert "back" in device.pressed_keys, "Story loop detected foreign app but never pressed BACK to recover!"
|
||||
Reference in New Issue
Block a user