Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/__pycache__/
**/__pycache__/
/.superpowers/
/venv/
BlueStacksRootGUI*.spec
/dist/
Expand Down
23 changes: 23 additions & 0 deletions adb_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,26 @@ def _p(msg):
"\"Apps and ADB\" and try again. The zip was also copied to the "
"instance's Download folder -- or flash it there: Modules -> Install "
"from storage -> Download." % (out or "unknown error"))


def list_running_instances(adb_exe: str, instances, runner: Runner = _run) -> dict:
"""Which of ``instances`` are currently reachable over ADB.

``instances`` is an iterable of (unique_id, config_path, original_name).
Returns {unique_id: port} for every instance whose configured ADB port
is currently connected. Instances with no recorded port (never booted)
are skipped without spawning a process.
"""
running = {}
for unique_id, config_path, name in instances:
port = instance_adb_port(config_path, name)
if port is None:
continue
try:
cp = runner([adb_exe, "connect", "127.0.0.1:%d" % port])
out = (cp.stdout or "") + (cp.stderr or "")
if "connected" in out.lower():
running[unique_id] = port
except Exception as exc: # noqa: BLE001 - one bad instance mustn't abort the rest
logger.warning("ADB probe failed for %s: %s", unique_id, exc)
return running
28 changes: 28 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# The empty-on-purpose part: this file's presence at the repo root makes pytest
# insert the repo root onto sys.path (prepend import mode), so tests can
# `import constants`, `import adb_handler`, and `from views import ...` the same
# way main.py does, without a src/ layout or package install.

import pytest


@pytest.fixture(autouse=True)
def _no_real_bluestacks_detection(monkeypatch):
"""Make the live registry probe a no-op for every test.

MainWindow.__init__ schedules initialize_paths_and_instances() via
QTimer.singleShot(0, ...). It never runs during a test's synchronous body,
but pytest-qt spins the Qt event loop at teardown, which fires it. On a
machine WITHOUT BlueStacks that init returns early (no installations found);
on a dev machine WITH BlueStacks it proceeds to read the real engine state --
draining tests' monkeypatched finite _engine_state iterators (StopIteration)
and byte-scanning real binaries. That made the suite pass or fail depending
on the host. Stubbing detection to "nothing installed" makes the stray init
harmless everywhere; tests that need installations set them on the window
directly after construction.
"""
monkeypatch.setattr(
"registry_handler.get_all_bluestacks_installations",
lambda: [],
raising=True,
)
642 changes: 6 additions & 636 deletions main.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pytest>=8.0
pytest-qt>=4.4
Empty file added tests/__init__.py
Empty file.
49 changes: 49 additions & 0 deletions tests/test_adb_handler_running.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from types import SimpleNamespace

from adb_handler import list_running_instances


def _fake_runner(responses):
calls = []

def runner(cmd):
calls.append(cmd)
key = cmd[-1] if len(cmd) >= 2 and cmd[1] == "connect" else None
stdout = responses.get(key, "")
return SimpleNamespace(stdout=stdout, stderr="", returncode=0)

runner.calls = calls
return runner


def test_list_running_instances_returns_connected_ports(tmp_path):
config_path = tmp_path / "bluestacks.conf"
config_path.write_text(
'bst.instance.Pie64.status.adb_port="5555"\n'
'bst.instance.Idle.status.adb_port="5565"\n'
)
responses = {
"127.0.0.1:5555": "connected to 127.0.0.1:5555",
"127.0.0.1:5565": "failed to connect to 127.0.0.1:5565",
}
runner = _fake_runner(responses)
instances = [
("Pie64 (Normal)", str(config_path), "Pie64"),
("Idle (Normal)", str(config_path), "Idle"),
]

result = list_running_instances("HD-Adb.exe", instances, runner=runner)

assert result == {"Pie64 (Normal)": 5555}


def test_list_running_instances_skips_instances_with_no_recorded_port(tmp_path):
config_path = tmp_path / "bluestacks.conf"
config_path.write_text("") # no adb_port keys at all
runner = _fake_runner({})
instances = [("NeverBooted (Normal)", str(config_path), "NeverBooted")]

result = list_running_instances("HD-Adb.exe", instances, runner=runner)

assert result == {}
assert runner.calls == [] # never attempted an adb connect
64 changes: 64 additions & 0 deletions tests/test_dashboard_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from PyQt5.QtCore import Qt

from views.dashboard_page import DashboardPage


def test_alert_hidden_by_default(qtbot):
page = DashboardPage()
qtbot.addWidget(page)
page.show()
assert page.alert_label.isVisible() is False
assert page.repatch_button.isVisible() is False


def test_set_update_reverted_shows_alert(qtbot):
page = DashboardPage()
qtbot.addWidget(page)
page.show()
page.set_update_reverted(True)
assert page.alert_label.isVisible() is True
assert page.repatch_button.isVisible() is True


def test_clicking_repatch_emits_signal(qtbot):
page = DashboardPage()
qtbot.addWidget(page)
page.show()
page.set_update_reverted(True)
with qtbot.waitSignal(page.repatch_requested, timeout=1000):
qtbot.mouseClick(page.repatch_button, Qt.LeftButton)


def test_set_engine_state_updates_button(qtbot):
page = DashboardPage()
qtbot.addWidget(page)
page.show()
page.set_engine_state(True, "Patch BlueStacks Engine", "tip", "#c62828", True)
assert page.engine_button.isVisible() is True
assert page.engine_button.text() == "Patch BlueStacks Engine"
assert page.engine_button.isEnabled() is True


def test_clicking_engine_button_emits_signal(qtbot):
page = DashboardPage()
qtbot.addWidget(page)
page.show()
page.set_engine_state(True, "Patch", "tip", "#c62828", True)
with qtbot.waitSignal(page.patch_engine_requested, timeout=1000):
qtbot.mouseClick(page.engine_button, Qt.LeftButton)


def test_set_rooted_count_updates_label(qtbot):
page = DashboardPage()
qtbot.addWidget(page)
page.show()
page.set_rooted_count(2, 5)
assert page.stat_label.text() == "2 / 5 instances rooted"


def test_set_paths_text_updates_label(qtbot):
page = DashboardPage()
qtbot.addWidget(page)
page.show()
page.set_paths_text("Installations Found:\n - NXT v5.22.232.1002: C:/x")
assert page.path_label.text() == "Installations Found:\n - NXT v5.22.232.1002: C:/x"
66 changes: 66 additions & 0 deletions tests/test_engine_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from views.engine_rules import blocked_for_root_toggle, update_was_reverted


def test_blocked_for_root_toggle_blocks_unpatched_patch_mode_instances():
selected = {
"Pie64 (Normal)": {"patch_mode": True},
"MSI_Pie (MSI)": {"patch_mode": False},
}
assert blocked_for_root_toggle(selected, "unpatched") == ["Pie64 (Normal)"]


def test_blocked_for_root_toggle_allows_everything_when_patched():
selected = {
"Pie64 (Normal)": {"patch_mode": True},
"MSI_Pie (MSI)": {"patch_mode": False},
}
assert blocked_for_root_toggle(selected, "patched") == []


def test_blocked_for_root_toggle_ignores_classic_instances():
selected = {"MSI_Pie (MSI)": {"patch_mode": False}}
assert blocked_for_root_toggle(selected, "unpatched") == []


def test_blocked_for_root_toggle_blocks_turning_root_on():
selected = {"Pie64 (Normal)": {"patch_mode": True, "root_enabled": False}}
assert blocked_for_root_toggle(selected, "unpatched") == ["Pie64 (Normal)"]


def test_blocked_for_root_toggle_allows_turning_root_off():
# A currently-rooted patch-mode instance being toggled OFF is not blocked:
# disabling root needs no engine patch, so a stale root after an
# auto-update revert must still be clearable.
selected = {"Pie64 (Normal)": {"patch_mode": True, "root_enabled": True}}
assert blocked_for_root_toggle(selected, "unpatched") == []


def test_blocked_for_root_toggle_blocks_on_partial():
selected = {"Pie64 (Normal)": {"patch_mode": True, "root_enabled": False}}
assert blocked_for_root_toggle(selected, "partial") == ["Pie64 (Normal)"]


def test_blocked_for_root_toggle_allows_when_engine_state_unknown():
# Unrecognized build: we can't read patch state, so don't trap the user.
selected = {"Pie64 (Normal)": {"patch_mode": True, "root_enabled": False}}
assert blocked_for_root_toggle(selected, "unknown") == []


def test_update_was_reverted_true_when_patch_lost_with_root_active():
assert update_was_reverted("patched", "unpatched", any_rooted=True) is True


def test_update_was_reverted_false_when_nothing_was_rooted():
assert update_was_reverted("patched", "unpatched", any_rooted=False) is False


def test_update_was_reverted_false_when_still_patched():
assert update_was_reverted("patched", "patched", any_rooted=True) is False


def test_update_was_reverted_false_when_never_was_patched():
assert update_was_reverted("unpatched", "unpatched", any_rooted=True) is False


def test_update_was_reverted_false_on_first_ever_check():
assert update_was_reverted(None, "unpatched", any_rooted=True) is False
82 changes: 82 additions & 0 deletions tests/test_instances_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from PyQt5.QtCore import Qt

import constants
from views.instances_page import InstancesPage


def test_banner_hidden_by_default(qtbot):
page = InstancesPage()
qtbot.addWidget(page)
page.show()
assert page.banner_label.isVisible() is False


def test_set_engine_locked_banner_shows_banner(qtbot):
page = InstancesPage()
qtbot.addWidget(page)
page.show()
page.set_engine_locked_banner(True)
assert page.banner_label.isVisible() is True
assert page.banner_fix_button.isVisible() is True


def test_set_instances_creates_one_row_per_instance(qtbot):
page = InstancesPage()
qtbot.addWidget(page)
page.show()
data = {
"Pie64 (Normal)": {"root_enabled": True, "rw_mode": constants.MODE_READWRITE},
"MSI_Pie (MSI)": {"root_enabled": False, "rw_mode": constants.MODE_READONLY},
}
page.set_instances(data)
assert set(page.checkboxes.keys()) == {"Pie64 (Normal)", "MSI_Pie (MSI)"}


def test_selected_ids_reflects_checked_boxes(qtbot):
page = InstancesPage()
qtbot.addWidget(page)
page.show()
data = {"Pie64 (Normal)": {"root_enabled": True, "rw_mode": constants.MODE_READWRITE}}
page.set_instances(data)
page.checkboxes["Pie64 (Normal)"].setChecked(True)
assert page.selected_ids() == ["Pie64 (Normal)"]


def test_set_instances_preserves_selection_across_refresh(qtbot):
page = InstancesPage()
qtbot.addWidget(page)
page.show()
data = {"Pie64 (Normal)": {"root_enabled": True, "rw_mode": constants.MODE_READWRITE}}
page.set_instances(data)
page.checkboxes["Pie64 (Normal)"].setChecked(True)

page.set_instances(data, preserve_selection=True)
assert page.checkboxes["Pie64 (Normal)"].isChecked() is True


def test_set_instances_can_clear_selection(qtbot):
page = InstancesPage()
qtbot.addWidget(page)
page.show()
data = {"Pie64 (Normal)": {"root_enabled": True, "rw_mode": constants.MODE_READWRITE}}
page.set_instances(data)
page.checkboxes["Pie64 (Normal)"].setChecked(True)

page.set_instances(data, preserve_selection=False)
assert page.checkboxes["Pie64 (Normal)"].isChecked() is False


def test_clicking_toggle_root_emits_signal(qtbot):
page = InstancesPage()
qtbot.addWidget(page)
page.show()
with qtbot.waitSignal(page.toggle_root_requested, timeout=1000):
qtbot.mouseClick(page.root_toggle_button, Qt.LeftButton)


def test_clicking_fix_it_emits_go_to_dashboard(qtbot):
page = InstancesPage()
qtbot.addWidget(page)
page.show()
with qtbot.waitSignal(page.go_to_dashboard_requested, timeout=1000):
qtbot.mouseClick(page.banner_fix_button, Qt.LeftButton)
7 changes: 7 additions & 0 deletions tests/test_main_bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import importlib


def test_main_module_imports_without_running_gui():
main = importlib.import_module("main")
assert main.LOG_PATH.endswith("BlueStacksRootGUI.log")
assert hasattr(main, "MainWindow")
34 changes: 34 additions & 0 deletions tests/test_main_window_close_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from unittest.mock import MagicMock

from PyQt5.QtWidgets import QMessageBox

from views.main_window import MainWindow


def test_close_blocked_while_operation_running(qtbot, monkeypatch):
# Closing mid-operation would kill a thread writing real binaries/disk
# images. closeEvent must warn and refuse instead of tearing down.
window = MainWindow()
qtbot.addWidget(window)
window._op_thread = object() # stand-in for an in-flight operation
warned = MagicMock()
monkeypatch.setattr(QMessageBox, "warning", warned)
event = MagicMock()

window.closeEvent(event)

warned.assert_called_once()
event.ignore.assert_called_once()
event.accept.assert_not_called()


def test_close_allowed_when_idle(qtbot):
window = MainWindow()
qtbot.addWidget(window)
window._op_thread = None
event = MagicMock()

window.closeEvent(event)

event.accept.assert_called_once()
event.ignore.assert_not_called()
Loading