mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
1357 lines
75 KiB
Python
1357 lines
75 KiB
Python
import contextlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import runpy
|
|
import shutil
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from unittest import mock
|
|
import zlib
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
BIN = REPO / "tools/linuxfs/overlay/usr/local/bin"
|
|
BNET = runpy.run_path(str(BIN / "droiddeck-battlenet"))["main"].__globals__
|
|
BNET["CACHE"] = tempfile.mkdtemp(prefix="bnet-test-cache-")
|
|
BNET["LOG"] = os.path.join(BNET["CACHE"], "battlenet.log")
|
|
RUN = runpy.run_path(str(BIN / "droiddeck-proton-run"))["main"].__globals__
|
|
STORE = runpy.run_path(str(BIN / "droiddeck-store-launch"))["main"].__globals__
|
|
KOTLIN = REPO / "app/src/main/java/com/droiddeck/launcher/runtime/BattleNet.kt"
|
|
|
|
|
|
class Temp(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = Path(tempfile.mkdtemp())
|
|
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
|
|
def write(self, relative, data, mode=0o644):
|
|
path = self.tmp / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data if isinstance(data, bytes) else data.encode())
|
|
path.chmod(mode)
|
|
return path
|
|
|
|
|
|
class LogTest(Temp):
|
|
def test_each_line_is_kept_and_a_long_log_rotates(self):
|
|
log = self.tmp / "battlenet.log"
|
|
with open(os.devnull, "w") as null, \
|
|
mock.patch.dict(BNET, {"CACHE": str(self.tmp), "LOG": str(log), "LOG_LIMIT": 64}), \
|
|
mock.patch.object(BNET["sys"], "stderr", null):
|
|
BNET["say"]("first line, long enough to pass the limit of sixty-four bytes")
|
|
BNET["say"]("second")
|
|
self.assertIn("first line", (self.tmp / "battlenet.log.1").read_text())
|
|
self.assertRegex(log.read_text(), r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d \[\d+\] second\n$")
|
|
|
|
|
|
class IdTest(unittest.TestCase):
|
|
def test_the_prefix_id_is_the_shortcut_id_the_app_names(self):
|
|
self.assertEqual(int(BNET["APP_ID"]), zlib.crc32(b'"droiddeck-battlenet"Battle.net') | 0x80000000)
|
|
found = re.search(r"const val APP_ID = (\d+)L", KOTLIN.read_text())
|
|
self.assertEqual(found.group(1), BNET["APP_ID"])
|
|
for name in ("FEX_REQUEST", "FEX_RESULT"):
|
|
self.assertIn('const val %s = "%s"' % (name, BNET[name]), KOTLIN.read_text())
|
|
|
|
|
|
class ProtonRunOptionsTest(Temp):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.root = self.tmp / "Steam"
|
|
folder = self.root / "compatibilitytools.d/droiddeck-proton-arm64"
|
|
self.write("Steam/compatibilitytools.d/droiddeck-proton-arm64/compatibilitytool.vdf",
|
|
'"compatibilitytools"\n{\n "compat_tools"\n {\n "droiddeck-proton-arm64"\n {\n'
|
|
' "install_path" "."\n "display_name" "x"\n "from_oslist" "windows"\n'
|
|
' "to_oslist" "linux"\n }\n }\n}\n')
|
|
self.write("Steam/compatibilitytools.d/droiddeck-proton-arm64/toolmanifest.vdf",
|
|
'"manifest"\n{\n "commandline" "/droiddeck-proton %verb%"\n}\n')
|
|
self.write("Steam/compatibilitytools.d/droiddeck-proton-arm64/droiddeck-proton", b"#!/bin/sh\n", 0o755)
|
|
self.entry = str(folder / "droiddeck-proton")
|
|
patcher = mock.patch.dict(RUN, {"EXTRA_LIBRARIES": (), "SYSTEM_TOOLS": (), "STATE": str(self.tmp / "state.json"),
|
|
"say": lambda message: None})
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def run_main(self, argv):
|
|
calls = []
|
|
with mock.patch.dict(os.environ, {"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(self.root)}), \
|
|
mock.patch.object(RUN["os"], "execve", lambda *a: calls.append(a)), mock.patch.object(RUN["os"], "chdir"):
|
|
status = RUN["main"](argv)
|
|
return status, calls
|
|
|
|
def test_a_named_prefix_wins_over_where_the_program_lies(self):
|
|
setup = str(self.write("Download/Battle.net-Setup.exe", b"MZ"))
|
|
status, calls = self.run_main(["--window", "--prefix", "2596113170", setup])
|
|
self.assertIsNone(status)
|
|
entry, argv, env = calls[0]
|
|
self.assertEqual(argv, [self.entry, "waitforexitandrun", setup])
|
|
self.assertEqual(env["STEAM_COMPAT_DATA_PATH"], str(self.root / "steamapps/compatdata/2596113170"))
|
|
self.assertEqual((env["SteamAppId"], env["SteamGameId"], env["PROTON_USE_WINED3D"]),
|
|
("0", str(2596113170 << 32 | 0x02000000), "1"))
|
|
self.assertTrue((self.root / "steamapps/compatdata/2596113170").is_dir())
|
|
|
|
def test_run_starts_beside_what_runs_in_the_prefix(self):
|
|
client = str(self.write("Steam/steamapps/compatdata/2596113170/pfx/drive_c/Battle.net.exe", b"MZ"))
|
|
_, calls = self.run_main(["--verb", "run", client])
|
|
self.assertEqual(calls[0][1], [self.entry, "run", client])
|
|
|
|
def test_a_launch_from_steam_keeps_its_game_id_for_gamescope(self):
|
|
client = str(self.write("Steam/steamapps/compatdata/2596113170/pfx/drive_c/Battle.net.exe", b"MZ"))
|
|
_, calls = self.run_main(["--prefix", "2596113170", "--game-id", "11151176786366644224", client])
|
|
env = calls[0][2]
|
|
self.assertEqual((env["SteamGameId"], env["SteamOverlayGameId"]), ("11151176786366644224", "11151176786366644224"))
|
|
self.assertEqual(env["STEAM_COMPAT_DATA_PATH"], str(self.root / "steamapps/compatdata/2596113170"))
|
|
self.assertNotIn("PROTON_USE_WINED3D", env)
|
|
self.assertEqual(self.run_main(["--game-id", "0", client]), (2, []))
|
|
|
|
def test_start_without_steam_has_droiddeck_battlenet_launch_a_games_shortcut(self):
|
|
client = str(self.write("Steam/steamapps/compatdata/2596113170/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe", b"MZ"))
|
|
prefix = str(self.root / "steamapps/compatdata/2596113170")
|
|
options = 'STEAM_COMPAT_DATA_PATH=%s %%command%% --exec="launch Fen"' % prefix
|
|
status, calls = self.run_main(["--app", "3000000000", "--dir", os.path.dirname(client), "--options", options, "--", client])
|
|
self.assertIsNone(status)
|
|
_, argv, env = calls[0]
|
|
self.assertEqual(argv, [self.entry, "waitforexitandrun", client, "--exec=launch Fen"])
|
|
self.assertEqual(env["STEAM_COMPAT_DATA_PATH"], prefix)
|
|
self.assertEqual(STORE["battlenet"](argv[2:], env={}), [STORE["BATTLENET"], "launch", "Fen"])
|
|
|
|
def test_a_bad_prefix_or_verb_is_refused(self):
|
|
setup = str(self.write("Download/setup.exe", b"MZ"))
|
|
for argv in (["--prefix", "0", setup], ["--prefix", "07", setup], ["--prefix", "4294967296", setup],
|
|
["--prefix", "abc", setup], ["--prefix"], ["--verb", "getcompatpath", setup]):
|
|
with self.subTest(argv):
|
|
self.assertEqual(self.run_main(list(argv)), (2, []))
|
|
|
|
|
|
class ConfigAndMenuTest(Temp):
|
|
def config_path(self):
|
|
return self.tmp / ("steamapps/compatdata/%s/pfx/drive_c/users/steamuser/AppData/Roaming/Battle.net/Battle.net.config"
|
|
% BNET["APP_ID"])
|
|
|
|
def test_the_config_keeps_browser_hardware_acceleration_off(self):
|
|
root = str(self.tmp)
|
|
self.assertEqual(BNET["write_config"](root), "first")
|
|
path = self.config_path()
|
|
config = json.loads(path.read_text())
|
|
self.assertEqual(config["Client"]["HardwareAcceleration"], "false")
|
|
self.assertEqual(config["Client"]["Streaming"]["StreamingEnabled"], "false")
|
|
self.assertEqual(config["Client"]["GameLaunchWindowBehavior"], "2")
|
|
self.assertIsNone(BNET["write_config"](root))
|
|
path.write_text('{"Client": {"HardwareAcceleration": "false", "GameLaunchWindowBehavior": "0"}, "Games": {"osi": {}}}')
|
|
self.assertEqual(BNET["write_config"](root), "launch 32-bit")
|
|
config = json.loads(path.read_text())
|
|
self.assertEqual(config, {"Client": {"HardwareAcceleration": "false", "GameLaunchWindowBehavior": "2"},
|
|
"Games": {"osi": {}, "s1": {"Run64Bit": "false", "Run64BitSet": "true"}}})
|
|
path.write_text('{"Client": {"HardwareAcceleration": "true", "GameLaunchWindowBehavior": "2"}}')
|
|
self.assertEqual(BNET["write_config"](root), "acceleration 32-bit")
|
|
self.assertEqual(json.loads(path.read_text())["Client"]["HardwareAcceleration"], "false")
|
|
|
|
def test_starcraft_starts_its_32_bit_client_until_a_player_picks_one(self):
|
|
root = str(self.tmp)
|
|
BNET["write_config"](root)
|
|
self.assertEqual(json.loads(self.config_path().read_text())["Games"], {"s1": {"Run64Bit": "false", "Run64BitSet": "true"}})
|
|
self.config_path().write_text('{"Client": {"HardwareAcceleration": "false", "GameLaunchWindowBehavior": "2"}, '
|
|
'"Games": {"s1": {"ServerUid": "s1", "Run64Bit": "true"}}}')
|
|
self.assertEqual(BNET["write_config"](root), "32-bit")
|
|
self.assertEqual(json.loads(self.config_path().read_text())["Games"]["s1"],
|
|
{"ServerUid": "s1", "Run64Bit": "false", "Run64BitSet": "true"})
|
|
self.config_path().write_text('{"Client": {"HardwareAcceleration": "false", "GameLaunchWindowBehavior": "2"}, '
|
|
'"Games": {"s1": {"Run64Bit": "true", "Run64BitSet": "true"}}}')
|
|
self.assertIsNone(BNET["write_config"](root))
|
|
self.config_path().write_text('{"Client": {"HardwareAcceleration": "false", "GameLaunchWindowBehavior": "2"}, "Games": []}')
|
|
self.assertIsNone(BNET["write_config"](root))
|
|
|
|
def test_games_install_to_the_chosen_library(self):
|
|
root = str(self.tmp)
|
|
BNET["write_config"](root)
|
|
self.assertEqual(BNET["write_config"](root, "/root/Games/Stores/Battle.net"), "library")
|
|
config = json.loads(self.config_path().read_text())
|
|
self.assertEqual(config["Client"]["Install"]["DefaultInstallPath"], "Z:/root/Games/Stores/Battle.net")
|
|
self.assertIsNone(BNET["write_config"](root, "/root/Games/Stores/Battle.net/"))
|
|
config["Client"]["Install"]["DefaultInstallPath"] = "Z:/root/Games/Stores/Battle.net/"
|
|
self.config_path().write_text(json.dumps(config))
|
|
self.assertIsNone(BNET["write_config"](root, "/root/Games/Stores/Battle.net"))
|
|
self.assertEqual(BNET["write_config"](root, "/mnt/droiddeck-sd/Games/Battle.net"), "library")
|
|
|
|
def test_a_config_that_is_not_json_is_left_alone(self):
|
|
path = self.config_path()
|
|
path.parent.mkdir(parents=True)
|
|
for text in ("{not json", "[1, 2]", '{"Client": "x"}'):
|
|
with self.subTest(text):
|
|
path.write_text(text)
|
|
self.assertIsNone(BNET["write_config"](str(self.tmp)))
|
|
self.assertEqual(path.read_text(), text)
|
|
|
|
def test_the_menu_entry_is_a_window_not_a_game(self):
|
|
path = self.tmp / "applications/droiddeck-battlenet.desktop"
|
|
BNET["write_menu"](str(path))
|
|
text = path.read_text()
|
|
self.assertIn("Exec=/usr/local/bin/droiddeck-battlenet\n", text)
|
|
self.assertIn("Name=Battle.net\n", text)
|
|
self.assertNotIn("Game", re.search(r"^Categories=(.*)$", text, re.M).group(1))
|
|
self.assertNotIn("StartupWMClass", text)
|
|
mtime = path.stat().st_mtime_ns
|
|
time.sleep(0.01)
|
|
BNET["write_menu"](str(path))
|
|
self.assertEqual(path.stat().st_mtime_ns, mtime)
|
|
|
|
|
|
class GamepadTest(Temp):
|
|
def registry(self, *entries):
|
|
rows = ["WINE REGISTRY Version 2"]
|
|
for name, uid, location in entries:
|
|
rows += ["", "[Software\\\\Wow6432Node\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Uninstall\\\\%s] 1" % name,
|
|
'"InstallLocation"="%s"' % location.replace("\\", "\\\\"),
|
|
'"UninstallString"="\\"C:\\\\ProgramData\\\\Battle.net\\\\Agent\\\\Blizzard Uninstaller.exe\\" --uid=%s"' % uid]
|
|
self.write("Steam/steamapps/compatdata/%s/pfx/system.reg" % BNET["APP_ID"], "\n".join(rows) + "\n")
|
|
|
|
def z(self, path):
|
|
return "Z:" + str(path).replace("/", "\\")
|
|
|
|
def test_every_world_of_warcraft_flavor_gets_controllers_on(self):
|
|
wow = self.tmp / "Games/World of Warcraft"
|
|
retail = self.write("Games/World of Warcraft/_retail_/WTF/Config.wtf", 'SET gxApi "D3D11"')
|
|
(wow / "_classic_era_").mkdir(parents=True)
|
|
(wow / "Data").mkdir()
|
|
self.registry(("World of Warcraft", "wow", self.z(wow)))
|
|
with mock.patch.dict(BNET, {"say": lambda message: None}):
|
|
self.assertEqual(len(BNET["wow_settings"](str(self.tmp / "Steam"))), 2)
|
|
self.assertEqual(BNET["wow_settings"](str(self.tmp / "Steam")), [])
|
|
self.assertEqual(retail.read_text(), 'SET gxApi "D3D11"\nSET GamePadEnable "1"\n')
|
|
self.assertEqual((wow / "_classic_era_/WTF/Config.wtf").read_text(), 'SET GamePadEnable "1"\nSET gxApi "D3D11"\n')
|
|
self.assertFalse((wow / "Data/WTF").exists())
|
|
|
|
def test_directx_11_is_set_once_and_a_later_pick_is_kept(self):
|
|
wow = self.tmp / "Games/World of Warcraft"
|
|
retail = self.write("Games/World of Warcraft/_retail_/WTF/Config.wtf", 'SET GxApi "D3D12"\nSET GamePadEnable "1"\nSET vsync "1"\n')
|
|
self.registry(("World of Warcraft", "wow", self.z(wow)))
|
|
with mock.patch.dict(BNET, {"say": lambda message: None}):
|
|
self.assertEqual(len(BNET["wow_settings"](str(self.tmp / "Steam"))), 1)
|
|
self.assertEqual(retail.read_text(), 'SET gxApi "D3D11"\nSET GamePadEnable "1"\nSET vsync "1"\n')
|
|
self.assertTrue((wow / "_retail_/WTF/.droiddeck-d3d11").exists())
|
|
retail.write_text('SET gxApi "d3d12"\nSET GamePadEnable "1"\nSET vsync "1"\n')
|
|
self.assertEqual(BNET["wow_settings"](str(self.tmp / "Steam")), [])
|
|
self.assertEqual(retail.read_text(), 'SET gxApi "d3d12"\nSET GamePadEnable "1"\nSET vsync "1"\n')
|
|
|
|
def test_a_players_own_choice_and_other_games_are_left_alone(self):
|
|
wow = self.tmp / "Games/World of Warcraft Classic"
|
|
config = self.write("Games/World of Warcraft Classic/_anniversary_/WTF/Config.wtf", 'SET GamePadEnable "0"\n')
|
|
other = self.tmp / "Games/Diablo IV"
|
|
(other / "_retail_").mkdir(parents=True)
|
|
self.registry(("World of Warcraft Classic", "wow_classic_anniversary", self.z(wow)), ("Diablo IV", "fenris", self.z(other)))
|
|
with mock.patch.dict(BNET, {"say": lambda message: None}):
|
|
self.assertEqual(len(BNET["wow_settings"](str(self.tmp / "Steam"))), 1)
|
|
self.assertEqual(config.read_text(), 'SET GamePadEnable "0"\nSET gxApi "D3D11"\n')
|
|
self.assertFalse((other / "_retail_/WTF").exists())
|
|
|
|
def test_pads_look_like_an_xbox_360_pad_only_without_the_steam_client(self):
|
|
proc = self.tmp / "proc"
|
|
self.write("proc/10/cmdline", b"/usr/bin/python3\0/usr/local/bin/droiddeck-battlenet\0")
|
|
self.write("proc/11/cmdline", b"c:\\windows\\system32\\steam.exe\0")
|
|
self.assertFalse(BNET["steam_running"](str(proc)))
|
|
with mock.patch.dict(os.environ, {}, clear=False):
|
|
os.environ.pop("PROTON_SPOOF_STEAMINPUT_VIDPID", None)
|
|
with mock.patch.dict(BNET, {"steam_running": lambda: False}):
|
|
self.assertEqual(BNET["proton_env"]()["PROTON_SPOOF_STEAMINPUT_VIDPID"], "1")
|
|
with mock.patch.dict(BNET, {"steam_running": lambda: True}):
|
|
self.assertNotIn("PROTON_SPOOF_STEAMINPUT_VIDPID", BNET["proton_env"]())
|
|
os.environ["PROTON_SPOOF_STEAMINPUT_VIDPID"] = "0"
|
|
with mock.patch.dict(BNET, {"steam_running": lambda: False}):
|
|
self.assertEqual(BNET["proton_env"]()["PROTON_SPOOF_STEAMINPUT_VIDPID"], "0")
|
|
self.write("proc/12/cmdline", b"/usr/bin/FEX\0/root/.local/share/Steam/ubuntu12_32/steam\0-silent\0")
|
|
self.assertTrue(BNET["steam_running"](str(proc)))
|
|
|
|
|
|
class ProcessTest(Temp):
|
|
def proc(self, pid, argv, prefix=None):
|
|
env = b"HOME=/root\0" + (("WINEPREFIX=%s\0" % prefix).encode() if prefix else b"")
|
|
self.write("proc/%d/environ" % pid, env)
|
|
self.write("proc/%d/cmdline" % pid, b"\0".join(a.encode() for a in argv) + b"\0")
|
|
|
|
def test_the_prefixs_processes_are_found_by_their_wineprefix(self):
|
|
pfx = self.tmp / "compatdata/2596113170/pfx"
|
|
pfx.mkdir(parents=True)
|
|
self.proc(10, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe", "--from-launcher"], str(pfx) + "/")
|
|
self.proc(11, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9142\\Agent.exe"], str(pfx))
|
|
self.proc(12, ["C:\\Games\\Other.exe"], str(self.tmp / "compatdata/42/pfx"))
|
|
self.proc(13, ["/usr/bin/bash"])
|
|
self.write("proc/self/environ", b"")
|
|
found = BNET["wine_processes"](str(pfx), str(self.tmp / "proc"))
|
|
self.assertEqual(sorted(pid for pid, _ in found), [10, 11])
|
|
self.assertEqual([pid for pid, argv in found if BNET["is_client"](argv)], [10])
|
|
|
|
def test_only_battle_net_itself_counts_as_open(self):
|
|
self.assertTrue(BNET["is_client"](["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"]))
|
|
self.assertTrue(BNET["is_client"](["/root/.local/share/Steam/x/Battle.net.exe", "--x"]))
|
|
self.assertTrue(BNET["is_client"](["battle.net.exe"]))
|
|
self.assertFalse(BNET["is_client"](["C:\\Program Files (x86)\\Battle.net\\Battle.net Launcher.exe"]))
|
|
self.assertFalse(BNET["is_client"](["C:\\users\\steamuser\\Temp\\Battle.net-Setup.exe"]))
|
|
self.assertFalse(BNET["is_client"]([]))
|
|
|
|
def test_battle_nets_own_processes_are_those_under_its_folders(self):
|
|
for argv in (["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe", "--type=renderer"],
|
|
["C:/ProgramData/Battle.net/Agent/Agent.9824/Agent.exe"],
|
|
["C:\\Program Files (x86)\\Battle.net\\Battle.net Launcher.exe"],
|
|
["C:\\Program Files (x86)\\Battle.net\\Battle.net.17952\\BlizzardError.exe"]):
|
|
with self.subTest(argv):
|
|
self.assertTrue(BNET["is_battlenet"](argv))
|
|
for argv in (["Z:\\root\\.cache\\droiddeck\\battlenet\\Battle.net-Setup.exe"],
|
|
["Z:\\root\\Games\\Stores\\Battle.net\\Hearthstone\\Hearthstone.exe"],
|
|
["C:\\Program Files (x86)\\Battle.net\\World of Warcraft\\_forever_\\Wow-arm64.exe"],
|
|
["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe"], ["C:\\windows\\system32\\services.exe"], []):
|
|
with self.subTest(argv):
|
|
self.assertFalse(BNET["is_battlenet"](argv))
|
|
|
|
def test_a_game_battle_net_launched_carries_its_uid(self):
|
|
self.assertTrue(BNET["launched"](["C:\\Program Files (x86)\\Diablo II Resurrected\\D2R.exe", "-uid", "osi"]))
|
|
self.assertTrue(BNET["launched"](["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe", "-launch", "-UID", "fenris"]))
|
|
self.assertTrue(BNET["launched"](["C:\\Program Files (x86)\\Battle.net\\World of Warcraft\\_forever_\\Wow-arm64.exe",
|
|
"-launcherlogin", "-uid", "wowf"]))
|
|
for argv in (["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe"],
|
|
["C:\\Program Files (x86)\\Diablo IV\\vc_redist.x64.exe", "/quiet"],
|
|
["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe", "-uid", "battle.net"]):
|
|
with self.subTest(argv):
|
|
self.assertFalse(BNET["launched"](argv))
|
|
|
|
def test_a_game_that_signs_in_with_battle_nets_token_says_so(self):
|
|
self.assertTrue(BNET["shares_sign_in"](["Diablo IV.exe", "-nostreamline", "-sso", "-launch", "-uid", "fenris"]))
|
|
self.assertFalse(BNET["shares_sign_in"](["D2R.exe", "-uid", "osi"]))
|
|
self.assertFalse(BNET["shares_sign_in"](["-sso.exe"]))
|
|
|
|
def test_battle_net_takes_work_once_its_agent_is_up(self):
|
|
client = (1, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"])
|
|
agent = (2, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9824\\Agent.exe"])
|
|
self.assertTrue(BNET["agent_up"]([client, agent]))
|
|
self.assertFalse(BNET["agent_up"]([client]))
|
|
self.assertFalse(BNET["agent_up"]([agent]))
|
|
|
|
def test_open_is_waited_for_then_closed(self):
|
|
states = iter([False, False, True, True, False])
|
|
with mock.patch.dict(BNET, {"running": lambda pfx: next(states)}), mock.patch.object(BNET["time"], "sleep"):
|
|
self.assertTrue(BNET["wait_while_open"]("/pfx", grace=60, poll=0))
|
|
with mock.patch.dict(BNET, {"running": lambda pfx: False}):
|
|
self.assertFalse(BNET["wait_while_open"]("/pfx", grace=0, poll=0))
|
|
|
|
|
|
class ProtonWaitTest(Temp):
|
|
class Process:
|
|
def __init__(self, test, waits):
|
|
self.test, self.waits = test, waits
|
|
|
|
def wait(self, timeout=None):
|
|
self.waits -= 1
|
|
if self.test.stopped or self.waits < 0:
|
|
return 0
|
|
raise BNET["subprocess"].TimeoutExpired("proton", timeout)
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.stopped = []
|
|
|
|
def run_proton(self, states, close_grace=0, waits=100):
|
|
states = iter(states)
|
|
last = []
|
|
|
|
def processes(pfx, proc="/proc"):
|
|
last[:] = next(states, last)
|
|
return list(last)
|
|
|
|
with mock.patch.dict(BNET, {"wine_processes": processes, "say": lambda message: None,
|
|
"stop": lambda found, wait=0: self.stopped.extend(pid for pid, _ in found)}), \
|
|
mock.patch.object(BNET["subprocess"], "Popen", lambda cmd, **kw: self.Process(self, waits)):
|
|
return BNET["proton"]("launcher.exe", watch="/pfx", poll=0, close_grace=close_grace)
|
|
|
|
def test_agent_and_wine_left_behind_are_stopped_once_battle_net_closed(self):
|
|
client = (1, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"])
|
|
agent = (2, ["C:/ProgramData/Battle.net/Agent/Agent.9824/Agent.exe", "--locale=enUS"])
|
|
wine = (3, ["C:\\windows\\system32\\services.exe"])
|
|
self.assertEqual(self.run_proton([[client, agent, wine], [agent, wine], [agent, wine]]), (0, True))
|
|
self.assertEqual(sorted(self.stopped), [2, 3])
|
|
|
|
def test_nothing_is_stopped_while_a_game_still_runs(self):
|
|
client = (1, ["Battle.net.exe"])
|
|
game = (4, ["C:\\Games\\Diablo IV\\Diablo IV.exe"])
|
|
self.assertEqual(self.run_proton([[client, game]] + [[game]] * 5, waits=6), (0, True))
|
|
self.assertEqual(self.stopped, [])
|
|
|
|
def test_a_restart_to_update_is_not_a_close(self):
|
|
client = (1, ["Battle.net.exe"])
|
|
agent = (2, ["Agent.exe"])
|
|
states = [[client, agent], [agent], [client, agent], [agent]]
|
|
with mock.patch.object(BNET["time"], "monotonic", side_effect=[0, 0, 5, 5, 10, 10, 30, 30, 30, 30]):
|
|
self.run_proton(states, close_grace=15)
|
|
self.assertEqual(self.stopped, [2])
|
|
|
|
def test_battle_net_is_closed_once_a_game_is_up_and_what_is_left_once_it_exits(self):
|
|
client = (1, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"])
|
|
agent = (2, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9824\\Agent.exe"])
|
|
wine = (3, ["C:\\windows\\system32\\services.exe"])
|
|
game = (4, ["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe", "-launch", "-uid", "fenris"])
|
|
tidy = BNET["Tidy"](settle=0, gone=60)
|
|
states = [[client, agent, wine], [client, agent, wine, game], [wine, game], [wine, game], [wine], [wine]]
|
|
with mock.patch.dict(BNET, {"notify": lambda *a, **k: self.fail("notified")}):
|
|
self.assertEqual(self.run_proton_tidy(states, tidy), (0, True))
|
|
self.assertEqual(self.stopped, [1, 2, 3])
|
|
|
|
def run_proton_tidy(self, states, tidy):
|
|
states = iter(states)
|
|
last = []
|
|
test = self
|
|
|
|
def processes(pfx, proc="/proc"):
|
|
last[:] = next(states, last)
|
|
return list(last)
|
|
|
|
class Process:
|
|
def wait(self, timeout=None):
|
|
if 3 in test.stopped:
|
|
return 0
|
|
raise BNET["subprocess"].TimeoutExpired("proton", timeout)
|
|
|
|
with mock.patch.dict(BNET, {"wine_processes": processes, "say": lambda message: None,
|
|
"stop": lambda found, wait=0: self.stopped.extend(pid for pid, _ in found)}), \
|
|
mock.patch.object(BNET["subprocess"], "Popen", lambda cmd, **kw: Process()):
|
|
return BNET["proton"]("launcher.exe", watch="/pfx", poll=0, close_grace=0, tidy=tidy)
|
|
|
|
def test_only_wine_and_agent_are_idle(self):
|
|
self.assertTrue(BNET["idle"]([(1, ["C:\\windows\\system32\\explorer.exe", "/desktop"]),
|
|
(2, ["/x/files/bin/wineserver"]), (3, ["c:\\windows\\system32\\steam.exe", "a"])]))
|
|
self.assertFalse(BNET["idle"]([(1, ["Battle.net.exe"])]))
|
|
self.assertFalse(BNET["idle"]([(1, ["Z:\\root\\.cache\\droiddeck\\battlenet\\Battle.net-Setup.exe"])]))
|
|
|
|
|
|
class RequestTest(Temp):
|
|
UP = [(1, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"]),
|
|
(2, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9437\\Agent.exe"])]
|
|
|
|
def test_the_apps_request_waits_for_battle_net_then_goes_to_it(self):
|
|
(self.tmp / "battlenet-request.00000000000000000001").write_text("install-game\nhs_beta\n")
|
|
calls = []
|
|
with mock.patch.dict(BNET, {"spawn": lambda exe, verb, args=(): calls.append((exe, verb, list(args))) or 0}):
|
|
self.assertFalse(BNET["serve_requests"]("/p/pfx", self.UP[:1], str(self.tmp)))
|
|
self.assertTrue((self.tmp / "battlenet-request.00000000000000000001").exists())
|
|
self.assertTrue(BNET["serve_requests"]("/p/pfx", self.UP, str(self.tmp)))
|
|
(self.tmp / "battlenet-request.00000000000000000002").write_text("launch\nWTCG\n")
|
|
self.assertTrue(BNET["serve_requests"]("/p/pfx", self.UP, str(self.tmp)))
|
|
(self.tmp / "battlenet-request.00000000000000000003").write_text("launch\nWTCG;reboot\n")
|
|
self.assertFalse(BNET["serve_requests"]("/p/pfx", self.UP, str(self.tmp)))
|
|
client = "/p/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe"
|
|
self.assertEqual(calls, [(client, "run", ["--install", "--game=hs_beta"]), (client, "run", ["--exec=launch WTCG"])])
|
|
self.assertEqual(list(self.tmp.iterdir()), [])
|
|
|
|
def test_requests_left_before_the_poll_are_all_served_in_order(self):
|
|
(self.tmp / "battlenet-request.00000000000000000020").write_text("launch\nOSI\n")
|
|
(self.tmp / "battlenet-request.00000000000000000010").write_text("install-game\nwow\n")
|
|
(self.tmp / "battlenet-request.00000000000000000030.tmp").write_text("install-game\nfenris\n")
|
|
calls = []
|
|
with mock.patch.dict(BNET, {"spawn": lambda exe, verb, args=(): calls.append(list(args)) or 0}):
|
|
self.assertTrue(BNET["serve_requests"]("/p/pfx", self.UP, str(self.tmp)))
|
|
self.assertEqual(calls, [["--install", "--game=wow"], ["--exec=launch OSI"]])
|
|
self.assertEqual([p.name for p in self.tmp.iterdir()], ["battlenet-request.00000000000000000030.tmp"])
|
|
|
|
|
|
class TidyTest(unittest.TestCase):
|
|
CLIENT = (1, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"])
|
|
PAGE = (2, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe", "--type=renderer"])
|
|
AGENT = (3, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9824\\Agent.exe"])
|
|
WINE = (4, ["C:\\windows\\system32\\services.exe"])
|
|
GAME = (5, ["C:\\Program Files (x86)\\Diablo II Resurrected\\D2R.exe", "-uid", "osi"])
|
|
|
|
def setUp(self):
|
|
self.stopped = []
|
|
self.notified = []
|
|
patcher = mock.patch.dict(BNET, {
|
|
"say": lambda message: None,
|
|
"stop": lambda found, wait=0: self.stopped.append(sorted(pid for pid, _ in found)),
|
|
"notify": lambda title, body, icon=None: self.notified.append(title),
|
|
})
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def run_tidy(self, tidy, timeline):
|
|
return [tidy(processes, now) for now, processes in timeline]
|
|
|
|
def test_battle_net_and_its_agent_close_once_the_game_has_run_a_while(self):
|
|
up = [self.CLIENT, self.PAGE, self.AGENT, self.WINE]
|
|
tidy = BNET["Tidy"](settle=20, gone=30)
|
|
closed = self.run_tidy(tidy, [(0, up), (5, up + [self.GAME]), (24, up + [self.GAME]), (25, up + [self.GAME]),
|
|
(40, up + [self.GAME])])
|
|
self.assertEqual(closed, [False, False, False, True, False])
|
|
self.assertEqual(self.stopped, [[1, 2, 3]])
|
|
self.assertEqual(self.notified, [])
|
|
|
|
def test_battle_net_beside_a_game_signing_in_with_its_token_is_left_open(self):
|
|
d4 = (6, ["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe", "-nostreamline", "-sso", "-launch", "-uid", "fenris"])
|
|
up = [self.CLIENT, self.PAGE, self.AGENT]
|
|
tidy = BNET["Tidy"](settle=20, gone=30)
|
|
closed = self.run_tidy(tidy, [(0, up + [d4]), (25, up + [d4]), (30, [self.CLIENT, self.PAGE, d4]),
|
|
(100, [self.CLIENT, self.PAGE, d4])])
|
|
self.assertEqual(closed, [False, False, False, False])
|
|
self.assertEqual(self.stopped, [])
|
|
self.assertEqual(self.notified, [])
|
|
|
|
def test_what_battle_net_left_beside_such_a_game_once_closed_itself_is_stopped(self):
|
|
d4 = (6, ["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe", "-sso", "-launch", "-uid", "fenris"])
|
|
tidy = BNET["Tidy"](settle=20, gone=30)
|
|
self.run_tidy(tidy, [(0, [self.CLIENT, self.AGENT, d4]), (5, [self.AGENT, d4]), (20, [self.AGENT, d4])])
|
|
self.assertEqual(self.stopped, [[3]])
|
|
|
|
def test_what_battle_net_did_not_launch_closes_nothing(self):
|
|
up = [self.CLIENT, self.AGENT]
|
|
redist = (6, ["C:\\Program Files (x86)\\Diablo IV\\vc_redist.x64.exe", "/install"])
|
|
tidy = BNET["Tidy"](settle=0, gone=30)
|
|
self.assertEqual(self.run_tidy(tidy, [(0, up + [redist]), (100, up + [redist])]), [False, False])
|
|
self.assertEqual(self.stopped, [])
|
|
|
|
def test_a_game_that_exits_and_starts_again_counts_afresh(self):
|
|
tidy = BNET["Tidy"](settle=20, gone=30)
|
|
up = [self.CLIENT, self.AGENT]
|
|
self.run_tidy(tidy, [(0, up + [self.GAME]), (20, up + [self.GAME]), (30, up), (40, up + [self.GAME]),
|
|
(55, up + [self.GAME]), (60, up + [self.GAME])])
|
|
self.assertEqual(self.stopped, [[1, 3], [1, 3]])
|
|
|
|
def test_battle_net_outliving_its_agent_is_closed(self):
|
|
tidy = BNET["Tidy"](settle=20, gone=30)
|
|
with_agent = [self.CLIENT, self.PAGE, self.AGENT, self.WINE]
|
|
without = [self.CLIENT, self.PAGE, self.WINE]
|
|
closed = self.run_tidy(tidy, [(0, with_agent), (10, without), (39, without), (40, without), (41, without)])
|
|
self.assertEqual(closed, [False, False, False, True, False])
|
|
self.assertEqual(self.stopped, [[1, 2]])
|
|
self.assertEqual(self.notified, ["Battle.net was closed"])
|
|
|
|
def test_an_agent_back_within_the_grace_keeps_battle_net(self):
|
|
tidy = BNET["Tidy"](settle=20, gone=30)
|
|
with_agent = [self.CLIENT, self.AGENT]
|
|
without = [self.CLIENT]
|
|
self.run_tidy(tidy, [(0, with_agent), (10, without), (35, with_agent), (50, without), (79, without)])
|
|
self.assertEqual(self.stopped, [])
|
|
|
|
def test_a_battle_net_still_starting_its_agent_is_left_alone(self):
|
|
tidy = BNET["Tidy"](settle=20, gone=30)
|
|
self.run_tidy(tidy, [(0, [self.CLIENT]), (100, [self.CLIENT]), (1000, [self.CLIENT, self.WINE])])
|
|
self.assertEqual(self.stopped, [])
|
|
|
|
def test_a_closed_battle_net_starts_its_count_over(self):
|
|
tidy = BNET["Tidy"](settle=20, gone=30)
|
|
self.run_tidy(tidy, [(0, [self.CLIENT, self.AGENT]), (10, [self.AGENT]), (20, [self.CLIENT]), (100, [self.CLIENT])])
|
|
self.assertEqual(self.stopped, [])
|
|
|
|
|
|
class FexRequestTest(Temp):
|
|
def answer(self, text, delay=0.2):
|
|
def respond():
|
|
request = self.tmp / BNET["FEX_REQUEST"]
|
|
deadline = time.monotonic() + 5
|
|
while not request.exists() and time.monotonic() < deadline:
|
|
time.sleep(0.02)
|
|
self.requested = request.read_text()
|
|
request.unlink()
|
|
time.sleep(delay)
|
|
(self.tmp / (BNET["FEX_RESULT"] + ".tmp")).write_text(text + "\n")
|
|
os.replace(self.tmp / (BNET["FEX_RESULT"] + ".tmp"), self.tmp / BNET["FEX_RESULT"])
|
|
thread = threading.Thread(target=respond)
|
|
thread.start()
|
|
self.addCleanup(thread.join)
|
|
|
|
def test_the_apps_answer_comes_back(self):
|
|
(self.tmp / BNET["FEX_RESULT"]).write_text("ok an-old-answer\n")
|
|
self.answer("ok FEXCore-2610-d2rfix28-linux.wcp")
|
|
self.assertEqual(BNET["ask_fex"](str(self.tmp), wait=5), "ok FEXCore-2610-d2rfix28-linux.wcp")
|
|
self.assertEqual(self.requested, "appid=%s\n" % BNET["APP_ID"])
|
|
self.assertFalse((self.tmp / BNET["FEX_RESULT"]).exists())
|
|
|
|
def test_no_answer_or_no_session_starts_without_one(self):
|
|
with mock.patch.dict(BNET, {"say": lambda message: None}):
|
|
self.assertIsNone(BNET["ask_fex"](str(self.tmp), wait=0.3))
|
|
self.assertIsNone(BNET["ask_fex"](None))
|
|
self.assertIsNone(BNET["ask_fex"](str(self.tmp / "gone")))
|
|
|
|
|
|
class OpenTest(Temp):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.root = str(self.tmp / "Steam")
|
|
self.calls = []
|
|
self.alive = []
|
|
self.menu = self.tmp / "droiddeck-battlenet.desktop"
|
|
patcher = mock.patch.dict(BNET, {
|
|
"ask_fex": lambda launch_dir, wait=0: "ok fex.wcp",
|
|
"say": lambda message: None, "notify": lambda *a, **k: self.calls.append(("notify", a[0])),
|
|
"wine_processes": lambda pfx, proc="/proc": list(self.alive),
|
|
"stop": lambda processes, wait=0: self.calls.append(("stop", [pid for pid, _ in processes])) or self.alive.clear(),
|
|
"write_menu": lambda path=None: self.calls.append(("menu",)),
|
|
"START_GRACE": 0, "POLL": 0,
|
|
})
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def client(self):
|
|
return self.write("Steam/steamapps/compatdata/%s/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe"
|
|
% BNET["APP_ID"], b"MZ")
|
|
|
|
def proton(self, effects):
|
|
effects = iter(effects)
|
|
|
|
def fake(program, args=(), verb=None, watch=None, poll=0):
|
|
status, seen, side = next(effects)
|
|
self.calls.append(("proton", os.path.basename(program), verb))
|
|
if side:
|
|
side()
|
|
return status, seen
|
|
return fake
|
|
|
|
def test_an_installed_client_opens_and_the_start_waits_for_it(self):
|
|
self.client()
|
|
with mock.patch.dict(BNET, {"proton": self.proton([(0, True, None)])}):
|
|
self.assertEqual(BNET["open_client"](self.root), 0)
|
|
self.assertEqual(self.calls, [("menu",), ("proton", "Battle.net Launcher.exe", None)])
|
|
|
|
def test_what_a_closed_battle_net_left_is_stopped_first(self):
|
|
self.client()
|
|
self.alive.append((40, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.exe"]))
|
|
with mock.patch.dict(BNET, {"proton": self.proton([(0, True, None)])}):
|
|
self.assertEqual(BNET["open_client"](self.root), 0)
|
|
self.assertEqual(self.calls[:2], [("menu",), ("stop", [40])])
|
|
|
|
def test_without_a_client_the_installer_runs_and_hands_over(self):
|
|
setup = self.write("cache/Battle.net-Setup.exe", b"MZ")
|
|
with mock.patch.dict(BNET, {"download": lambda: str(setup), "proton": self.proton([(0, True, self.client)])}):
|
|
self.assertEqual(BNET["open_client"](self.root), 0)
|
|
self.assertEqual([c for c in self.calls if c[0] != "notify"], [("menu",), ("proton", "Battle.net-Setup.exe", None)])
|
|
config = Path(self.root) / ("steamapps/compatdata/%s/pfx/drive_c/users/steamuser/AppData/Roaming/Battle.net/Battle.net.config"
|
|
% BNET["APP_ID"])
|
|
self.assertTrue(config.is_file())
|
|
|
|
def test_an_installer_that_did_not_start_it_is_followed_by_the_launcher(self):
|
|
setup = self.write("cache/Battle.net-Setup.exe", b"MZ")
|
|
effects = [(0, False, self.client), (0, True, None)]
|
|
with mock.patch.dict(BNET, {"download": lambda: str(setup), "proton": self.proton(effects)}):
|
|
self.assertEqual(BNET["open_client"](self.root), 0)
|
|
self.assertEqual([c[1] for c in self.calls if c[0] == "proton"], ["Battle.net-Setup.exe", "Battle.net Launcher.exe"])
|
|
|
|
def test_a_failed_install_says_so(self):
|
|
setup = self.write("cache/Battle.net-Setup.exe", b"MZ")
|
|
with mock.patch.dict(BNET, {"download": lambda: str(setup), "proton": self.proton([(1, False, None)])}):
|
|
self.assertEqual(BNET["open_client"](self.root), 1)
|
|
self.assertIn(("notify", "Battle.net did not install"), self.calls)
|
|
|
|
def test_no_download_is_no_install(self):
|
|
with mock.patch.dict(BNET, {"download": lambda: None, "proton": self.proton([])}):
|
|
self.assertEqual(BNET["open_client"](self.root), 1)
|
|
self.assertIn(("notify", "Battle.net could not be downloaded"), self.calls)
|
|
self.assertEqual(self.calls[0], ("menu",))
|
|
|
|
def test_a_cut_off_installer_download_is_not_kept(self):
|
|
import http.client
|
|
import io
|
|
|
|
class Response(io.BytesIO):
|
|
def __init__(self, body, length):
|
|
super().__init__(body)
|
|
self.headers = {"Content-Length": str(length)}
|
|
|
|
folder = str(self.tmp / "dl")
|
|
body = b"MZ" + b"\0" * (BNET["MIN_INSTALLER"] + 10)
|
|
with mock.patch.object(BNET["urllib"].request, "urlopen", lambda *a, **k: Response(body, len(body) + 100)), \
|
|
mock.patch.object(BNET["shutil"], "which", lambda name: None):
|
|
self.assertIsNone(BNET["download"]("https://example.invalid/setup.exe", folder))
|
|
self.assertEqual(os.listdir(folder), [])
|
|
|
|
def broken(*a, **k):
|
|
raise http.client.IncompleteRead(b"MZ")
|
|
with mock.patch.object(BNET["urllib"].request, "urlopen", broken), \
|
|
mock.patch.object(BNET["shutil"], "which", lambda name: None):
|
|
self.assertIsNone(BNET["download"]("https://example.invalid/setup.exe", folder))
|
|
self.assertEqual(os.listdir(folder), [])
|
|
|
|
with mock.patch.object(BNET["urllib"].request, "urlopen", lambda *a, **k: Response(body, len(body))):
|
|
self.assertEqual(BNET["download"]("https://example.invalid/setup.exe", folder), os.path.join(folder, BNET["SETUP"]))
|
|
|
|
def test_a_client_that_never_starts_is_reported(self):
|
|
self.client()
|
|
with mock.patch.dict(BNET, {"proton": self.proton([(3, False, None)])}):
|
|
self.assertEqual(BNET["open_client"](self.root), 3)
|
|
self.assertIn(("notify", "Battle.net did not start"), self.calls)
|
|
|
|
def test_a_second_start_shows_the_open_window_and_returns(self):
|
|
client = self.write("Steam/steamapps/compatdata/%s/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe"
|
|
% BNET["APP_ID"], b"MZ")
|
|
self.alive.extend([(50, [str(client)]), (51, ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9824\\Agent.exe"])])
|
|
started = []
|
|
with mock.patch.object(BNET["subprocess"], "Popen", lambda cmd, **kw: started.append((cmd, kw)) or mock.Mock()), \
|
|
mock.patch.dict(os.environ, {"BL_DESKTOP": "1"}), \
|
|
mock.patch.dict(BNET, {"CACHE": str(self.tmp / "cache"), "acquire": lambda: self.fail("took the lock")}):
|
|
self.assertIsNone(BNET["show"](self.root, wait=60, poll=0))
|
|
cmd, kw = started[0]
|
|
self.assertEqual(cmd, [BNET["PROTON_RUN"], "--window", "--prefix", BNET["APP_ID"], "--verb", "run", str(client)])
|
|
self.assertTrue(kw["start_new_session"])
|
|
self.assertEqual(kw["stdin"], BNET["subprocess"].DEVNULL)
|
|
self.assertEqual(kw["stdout"].name, str(self.tmp / "cache/spawned.log"))
|
|
self.assertIs(kw["stderr"], kw["stdout"])
|
|
|
|
def test_a_battle_net_without_its_agent_is_not_handed_the_start(self):
|
|
self.write("Steam/steamapps/compatdata/%s/pfx/drive_c/Program Files (x86)/Battle.net/Battle.net.exe"
|
|
% BNET["APP_ID"], b"MZ")
|
|
self.alive.append((50, ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"]))
|
|
tries = iter([None, None, "the lock"])
|
|
with mock.patch.dict(BNET, {"acquire": lambda: next(tries), "spawn": lambda *a, **k: self.fail("handed over")}):
|
|
self.assertEqual(BNET["show"](self.root, wait=60, poll=0), "the lock")
|
|
|
|
def test_a_second_start_while_the_first_ends_opens_battle_net_itself(self):
|
|
tries = iter([None, None, "the lock"])
|
|
with mock.patch.dict(BNET, {"acquire": lambda: next(tries)}):
|
|
self.assertEqual(BNET["show"](self.root, wait=60, poll=0), "the lock")
|
|
|
|
def test_a_second_start_gives_up_when_the_first_neither_opens_nor_ends(self):
|
|
with mock.patch.dict(BNET, {"acquire": lambda: None}):
|
|
self.assertIsNone(BNET["show"](self.root, wait=0, poll=0))
|
|
self.assertEqual(self.calls[-1], ("notify", "Battle.net is busy"))
|
|
|
|
def test_a_game_in_the_prefix_is_left_running_and_battle_net_starts_beside_it(self):
|
|
self.client()
|
|
self.alive.append((41, ["C:\\Program Files (x86)\\Overwatch\\_retail_\\Overwatch.exe"]))
|
|
with mock.patch.dict(BNET, {"proton": self.proton([(0, True, None)])}):
|
|
self.assertEqual(BNET["open_client"](self.root), 0)
|
|
self.assertNotIn("stop", [c[0] for c in self.calls])
|
|
self.assertIn(("proton", "Battle.net Launcher.exe", "run"), self.calls)
|
|
|
|
def test_one_start_at_a_time(self):
|
|
first = BNET["acquire"](str(self.tmp / "lock"))
|
|
self.assertIsNotNone(first)
|
|
self.assertIsNone(BNET["acquire"](str(self.tmp / "lock")))
|
|
first.close()
|
|
again = BNET["acquire"](str(self.tmp / "lock"))
|
|
self.assertIsNotNone(again)
|
|
again.close()
|
|
|
|
def test_status_says_where_it_is(self):
|
|
self.client()
|
|
status = BNET["status"](self.root)
|
|
self.assertEqual((status["appid"], status["installed"], status["running"]), (int(BNET["APP_ID"]), True, False))
|
|
self.assertTrue(status["launcher"].endswith("Battle.net/Battle.net Launcher.exe"))
|
|
|
|
|
|
class Harness(Temp):
|
|
CLIENT = ["C:\\Program Files (x86)\\Battle.net\\Battle.net.exe"]
|
|
GAME = ["C:\\Program Files (x86)\\Diablo IV\\Diablo IV.exe"]
|
|
AGENT = ["C:\\ProgramData\\Battle.net\\Agent\\Agent.9824\\Agent.exe"]
|
|
|
|
class Lock:
|
|
def __init__(self):
|
|
self.closed = False
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
self.close()
|
|
|
|
class Proton:
|
|
def wait(self, timeout=None):
|
|
return 0
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
env = mock.patch.dict(os.environ)
|
|
env.start()
|
|
self.addCleanup(env.stop)
|
|
self.root = str(self.tmp / "Steam")
|
|
self.calls = []
|
|
self.lock = self.Lock()
|
|
self.states = []
|
|
self.last = []
|
|
patcher = mock.patch.dict(BNET, {
|
|
"ask_fex": lambda launch_dir, wait=0: self.calls.append(("fex",)) or "ok fex.wcp",
|
|
"say": lambda message: None, "notify": lambda *a, **k: self.calls.append(("notify", a[0])),
|
|
"wine_processes": self.processes,
|
|
"stop": lambda processes, wait=0: self.calls.append(("stop", sorted(pid for pid, _ in processes))),
|
|
"start": lambda exe, verb=None: self.calls.append(("start", os.path.basename(exe), verb)) or self.Proton(),
|
|
"spawn": lambda program, verb, args=(): self.calls.append(("spawn", os.path.basename(program), verb, list(args))),
|
|
"acquire": lambda folder=None: self.lock,
|
|
"POLL": 0, "READY_SETTLE": 0, "GAME_GRACE": 0, "CLIENT_WAIT": 0,
|
|
})
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def processes(self, pfx, proc="/proc"):
|
|
if self.states:
|
|
self.last = self.states.pop(0)
|
|
return list(self.last)
|
|
|
|
def install(self):
|
|
for name in ("Battle.net Launcher.exe", "Battle.net.exe"):
|
|
self.write("Steam/steamapps/compatdata/%s/pfx/drive_c/Program Files (x86)/Battle.net/%s" % (BNET["APP_ID"], name), b"MZ")
|
|
|
|
def clients(self, n=5, first=10):
|
|
return [(first + i, self.CLIENT + (["--type=renderer"] if i else [])) for i in range(n)]
|
|
|
|
class LaunchTest(Harness):
|
|
def test_battle_net_is_opened_for_the_game_and_closed_after_it(self):
|
|
self.install()
|
|
up = self.clients() + [(30, self.AGENT)]
|
|
self.states = [[], [], up, up + [(40, self.GAME)], up, up]
|
|
self.assertEqual(BNET["launch"](self.root, "Fen"), 0)
|
|
self.assertEqual(self.calls, [
|
|
("fex",), ("start", "Battle.net Launcher.exe", None),
|
|
("spawn", "Battle.net.exe", "run", ["--exec=launch Fen"]),
|
|
("stop", [10, 11, 12, 13, 14, 30]),
|
|
])
|
|
self.assertTrue(self.lock.closed)
|
|
|
|
def test_battle_net_is_closed_once_the_game_is_up(self):
|
|
self.install()
|
|
up = self.clients() + [(30, self.AGENT)]
|
|
game = (40, self.GAME + ["-launch", "-uid", "fenris"])
|
|
wine = (50, ["C:\\windows\\system32\\services.exe"])
|
|
self.states = [[], [], up, up + [game, wine], [game, wine], [game, wine], [wine], [wine]]
|
|
with mock.patch.dict(BNET, {"GAME_SETTLE": 0}):
|
|
self.assertEqual(BNET["launch"](self.root, "Fen"), 0)
|
|
self.assertEqual(self.calls, [
|
|
("fex",), ("start", "Battle.net Launcher.exe", None),
|
|
("spawn", "Battle.net.exe", "run", ["--exec=launch Fen"]),
|
|
("stop", [10, 11, 12, 13, 14, 30]),
|
|
("stop", [50]),
|
|
])
|
|
|
|
def test_a_battle_net_opened_for_a_game_closes_itself_as_it_launches_it(self):
|
|
self.install()
|
|
self.states = [[], [], [], []]
|
|
BNET["launch"](self.root, "Fen")
|
|
config = Path(self.root) / ("steamapps/compatdata/%s/pfx/drive_c/users/steamuser/AppData/Roaming/Battle.net/Battle.net.config"
|
|
% BNET["APP_ID"])
|
|
self.assertEqual(json.loads(config.read_text())["Client"]["GameLaunchWindowBehavior"], "2")
|
|
|
|
def test_battle_net_beside_a_game_signing_in_with_its_token_closes_only_after_it(self):
|
|
self.install()
|
|
up = self.clients() + [(30, self.AGENT)]
|
|
d4 = (40, self.GAME + ["-sso", "-launch", "-uid", "fenris"])
|
|
self.states = [[], [], up, up + [d4], up + [d4], up + [d4], up, up]
|
|
with mock.patch.dict(BNET, {"GAME_SETTLE": 0}):
|
|
self.assertEqual(BNET["launch"](self.root, "Fen"), 0)
|
|
self.assertEqual([c for c in self.calls if c[0] == "stop"], [("stop", [10, 11, 12, 13, 14, 30])])
|
|
|
|
def test_a_game_installed_in_battle_nets_folder_is_followed_not_stopped(self):
|
|
self.install()
|
|
up = self.clients() + [(30, self.AGENT)]
|
|
wow = (40, ["C:\\Program Files (x86)\\Battle.net\\World of Warcraft\\_forever_\\Wow-arm64.exe", "-launcherlogin", "-uid", "wowf"])
|
|
wine = (50, ["C:\\windows\\system32\\services.exe"])
|
|
self.states = [[], [], up, up + [wow], [wow, wine], [wow, wine], [wine], [wine]]
|
|
self.assertEqual(BNET["launch"](self.root, "WoWF"), 0)
|
|
self.assertEqual([c for c in self.calls if c[0] == "stop"], [("stop", [50])])
|
|
|
|
def test_battle_net_closing_itself_as_it_launches_the_game_is_not_an_end(self):
|
|
game = (40, self.GAME + ["-uid", "fenris"])
|
|
self.states = [[], [game], [game], [], []]
|
|
times = iter([0, 5, 6, 10, 30])
|
|
with mock.patch.object(BNET["time"], "monotonic", lambda: next(times)):
|
|
self.assertTrue(BNET["follow"]("/pfx", poll=0, grace=15))
|
|
|
|
def test_battle_net_that_only_selected_the_game_is_asked_again(self):
|
|
up = self.clients()
|
|
game = (40, self.GAME + ["-uid", "s1"])
|
|
self.states = [up, up, up, up + [game], [game], [], []]
|
|
asks = []
|
|
times = iter([0, 16, 32, 33, 34, 50, 70])
|
|
with mock.patch.object(BNET["time"], "monotonic", lambda: next(times)):
|
|
self.assertTrue(BNET["follow"]("/pfx", poll=0, grace=15, ask=lambda: asks.append(1)))
|
|
self.assertEqual(len(asks), 2)
|
|
|
|
def test_battle_net_is_asked_at_most_three_times(self):
|
|
up = self.clients()
|
|
self.states = [up] * 6 + [[], []]
|
|
asks = []
|
|
times = iter([0, 20, 40, 60, 80, 100, 101, 130])
|
|
with mock.patch.object(BNET["time"], "monotonic", lambda: next(times)):
|
|
self.assertFalse(BNET["follow"]("/pfx", poll=0, grace=15, ask=lambda: asks.append(1)))
|
|
self.assertEqual(len(asks), 2)
|
|
|
|
def test_the_wallpaper_is_droiddecks_logo_on_its_dark_gradient(self):
|
|
bmp = BNET["wallpaper_bmp"](192, 108)
|
|
self.assertEqual(bmp[:2], b"BM")
|
|
self.assertEqual(int.from_bytes(bmp[2:6], "little"), len(bmp))
|
|
self.assertEqual((int.from_bytes(bmp[18:22], "little"), int.from_bytes(bmp[22:26], "little")), (192, 108))
|
|
|
|
def pixel(x, y):
|
|
at = 54 + (107 - y) * 192 * 3 + x * 3
|
|
return tuple(bmp[at + 2 - i] for i in range(3))
|
|
self.assertEqual(pixel(95, 54), (26, 159, 255))
|
|
self.assertTrue(all(c < 16 for c in pixel(0, 0)))
|
|
|
|
def test_battle_nets_desktop_shows_the_wallpaper(self):
|
|
self.install()
|
|
pfx = Path(self.root) / ("steamapps/compatdata/%s/pfx" % BNET["APP_ID"])
|
|
(pfx / "user.reg").write_text("WINE REGISTRY Version 2\n")
|
|
with mock.patch.dict(os.environ, {"BL_WIDTH": "160", "BL_HEIGHT": "90"}), \
|
|
mock.patch.dict(BNET, {"wallpaper_bmp": lambda w, h: b"BM%dx%d" % (w, h)}):
|
|
self.assertTrue(BNET["wallpaper"](self.root))
|
|
self.assertTrue(BNET["wallpaper"](self.root))
|
|
image = pfx / "drive_c/users/steamuser/AppData/Local/DroidDeck/wallpaper-160x90.bmp"
|
|
self.assertEqual(image.read_bytes(), b"BM160x90")
|
|
reg = (pfx / "user.reg").read_text()
|
|
self.assertEqual(reg.count('"Wallpaper"="C:\\\\users\\\\steamuser\\\\AppData\\\\Local\\\\DroidDeck\\\\wallpaper-160x90.bmp"'), 1)
|
|
self.assertIn('[Control Panel\\\\Desktop]', reg)
|
|
self.assertIn('"Background"="4 6 11"', reg)
|
|
self.assertIn('"WallpaperStyle"="0"', reg)
|
|
|
|
def test_a_large_desktop_gets_a_stretched_smaller_wallpaper(self):
|
|
self.install()
|
|
pfx = Path(self.root) / ("steamapps/compatdata/%s/pfx" % BNET["APP_ID"])
|
|
(pfx / "user.reg").write_text("WINE REGISTRY Version 2\n")
|
|
with mock.patch.dict(os.environ, {"BL_WIDTH": "2560", "BL_HEIGHT": "1600"}), \
|
|
mock.patch.dict(BNET, {"wallpaper_bmp": lambda w, h: b"BM%dx%d" % (w, h)}):
|
|
self.assertTrue(BNET["wallpaper"](self.root))
|
|
image = pfx / "drive_c/users/steamuser/AppData/Local/DroidDeck/wallpaper-1152x720.bmp"
|
|
self.assertEqual(image.read_bytes(), b"BM1152x720")
|
|
self.assertIn('"WallpaperStyle"="2"', (pfx / "user.reg").read_text())
|
|
|
|
def test_battle_nets_desktop_turns_windows_animations_off_once(self):
|
|
self.install()
|
|
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
|
|
reg.write_text('WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:30,00,02,80,12,00,00,00\n')
|
|
self.assertTrue(BNET["still_pages"](self.root))
|
|
self.assertFalse(BNET["still_pages"](self.root))
|
|
text = reg.read_text()
|
|
self.assertEqual(text.count('"UserPreferencesMask"=hex:30,00,02,80,10,00,00,00'), 1)
|
|
self.assertEqual(BNET["reg_read"](str(reg), BNET["DESKTOP_KEY"], "UserPreferencesMask"),
|
|
'"UserPreferencesMask"=hex:30,00,02,80,10,00,00,00')
|
|
|
|
def test_a_new_prefix_gets_wines_default_preferences_without_animations(self):
|
|
self.install()
|
|
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
|
|
reg.write_text("WINE REGISTRY Version 2\n")
|
|
self.assertTrue(BNET["still_pages"](self.root))
|
|
self.assertEqual(BNET["reg_read"](str(reg), BNET["DESKTOP_KEY"], "UserPreferencesMask"),
|
|
'"UserPreferencesMask"=hex:10,00,00,80,10,00,00,00')
|
|
|
|
def test_animations_already_off_and_other_preferences_are_left_alone(self):
|
|
self.install()
|
|
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
|
|
before = 'WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:9e,3e,07,80,31,00,00,00\n'
|
|
reg.write_text(before)
|
|
self.assertFalse(BNET["still_pages"](self.root))
|
|
self.assertEqual(reg.read_text(), before)
|
|
reg.write_text('WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:9e,3e,07,80,33,00,00,00\n')
|
|
self.assertTrue(BNET["still_pages"](self.root))
|
|
self.assertEqual(BNET["reg_read"](str(reg), BNET["DESKTOP_KEY"], "UserPreferencesMask"),
|
|
'"UserPreferencesMask"=hex:9e,3e,07,80,31,00,00,00')
|
|
self.assertFalse(BNET["still_pages"](self.root + "/missing"))
|
|
|
|
def test_a_prefix_in_use_keeps_its_registry_until_it_starts_on_its_own(self):
|
|
self.install()
|
|
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
|
|
before = 'WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:30,00,02,80,12,00,00,00\n'
|
|
reg.write_text(before)
|
|
with mock.patch.dict(BNET, {"wine_processes": lambda pfx: [(1, ["C:\\Games\\game.exe"])]}):
|
|
self.assertFalse(BNET["still_pages"](self.root))
|
|
self.assertEqual(reg.read_text(), before)
|
|
self.assertTrue(BNET["still_pages"](self.root))
|
|
|
|
def test_a_large_desktop_scales_battle_net_to_fit_it(self):
|
|
sizes = {("1280", "720"): 96, ("1920", "1080"): 96, ("2400", "1504"): 120, ("2448", "1848"): 120,
|
|
("1080", "2400"): 96, ("3840", "2160"): 192, ("2560", "1600"): 120}
|
|
for (width, height), want in sizes.items():
|
|
with mock.patch.dict(os.environ, {"BL_WIDTH": width, "BL_HEIGHT": height}):
|
|
self.assertEqual(BNET["desktop_dpi"](), want, (width, height))
|
|
|
|
def test_battle_nets_desktop_scale_is_written_once_and_follows_the_size(self):
|
|
self.install()
|
|
pfx = Path(self.root) / ("steamapps/compatdata/%s/pfx" % BNET["APP_ID"])
|
|
(pfx / "user.reg").write_text('WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"LogPixels"=dword:00000060\n')
|
|
with mock.patch.dict(os.environ, {"BL_WIDTH": "1280", "BL_HEIGHT": "720"}):
|
|
self.assertTrue(BNET["dpi"](self.root))
|
|
self.assertEqual((pfx / "user.reg").read_text().count("LogPixels"), 1)
|
|
with mock.patch.dict(os.environ, {"BL_WIDTH": "2448", "BL_HEIGHT": "1848"}):
|
|
self.assertTrue(BNET["dpi"](self.root))
|
|
self.assertTrue(BNET["dpi"](self.root))
|
|
reg = (pfx / "user.reg").read_text()
|
|
self.assertEqual(reg.count('"LogPixels"=dword:00000078'), 1)
|
|
self.assertTrue(reg.rstrip().endswith('"LogPixels"=dword:00000078'))
|
|
|
|
def test_an_open_battle_net_is_handed_the_game(self):
|
|
self.install()
|
|
up = self.clients()
|
|
self.states = [up, up, up + [(40, self.GAME)], up]
|
|
self.assertEqual(BNET["launch"](self.root, "OSI"), 0)
|
|
self.assertEqual(self.calls, [("spawn", "Battle.net.exe", "run", ["--exec=launch OSI"])])
|
|
|
|
def test_the_desktops_battle_net_holding_the_lock_is_handed_the_game(self):
|
|
self.install()
|
|
up = self.clients()
|
|
self.states = [up, up + [(40, self.GAME)], up]
|
|
with mock.patch.dict(BNET, {"acquire": lambda folder=None: None}):
|
|
self.assertEqual(BNET["launch"](self.root, "Pro"), 0)
|
|
self.assertEqual(self.calls, [("spawn", "Battle.net.exe", "run", ["--exec=launch Pro"])])
|
|
|
|
def test_battle_net_closed_before_the_game_started(self):
|
|
self.install()
|
|
up = self.clients()
|
|
self.states = [[], [], up, up, [(30, self.AGENT)], [(30, self.AGENT)]]
|
|
self.assertEqual(BNET["launch"](self.root, "Fen"), 1)
|
|
self.assertEqual(self.calls[-1], ("stop", [30]))
|
|
|
|
def test_no_client_no_launch(self):
|
|
self.assertEqual(BNET["launch"](self.root, "Fen"), 1)
|
|
self.assertEqual(self.calls, [("notify", "Battle.net is not installed")])
|
|
|
|
def test_a_battle_net_that_never_opens_launches_nothing(self):
|
|
self.install()
|
|
self.states = [[], [], []]
|
|
self.assertEqual(BNET["launch"](self.root, "Fen"), 1)
|
|
self.assertNotIn("spawn", [c[0] for c in self.calls])
|
|
self.assertIn(("notify", "Battle.net did not start"), self.calls)
|
|
|
|
def test_a_game_is_a_windows_program_neither_wines_nor_battle_nets(self):
|
|
self.assertTrue(BNET["is_game"](self.GAME))
|
|
self.assertTrue(BNET["is_game"](["c:/Program Files (x86)/Overwatch/_retail_/Overwatch.exe", "-uid", "prometheus"]))
|
|
self.assertTrue(BNET["is_game"](["C:\\Program Files (x86)\\Diablo IV\\BlizzardError\\BlizzardError.exe"]))
|
|
for argv in (self.CLIENT, self.AGENT, ["C:\\Program Files (x86)\\Battle.net\\Battle.net Launcher.exe"],
|
|
["C:\\windows\\system32\\services.exe"], ["C:\\windows\\system32\\lsass.exe"], ["C:\\windows\\system32\\rundll32.exe", "setupapi"],
|
|
["c:\\windows\\system32\\steam.exe", "x"], ["/x/files/bin/wineserver"], ["/usr/bin/python3", "proton"], []):
|
|
with self.subTest(argv):
|
|
self.assertFalse(BNET["is_game"](argv))
|
|
|
|
def test_battle_net_shows_in_the_desktops_window_or_as_the_steam_launch(self):
|
|
with mock.patch.dict(os.environ, {"BL_DESKTOP": "1", "SteamGameId": "123"}):
|
|
self.assertEqual(BNET["proton_options"](), ["--window"])
|
|
with mock.patch.dict(os.environ, {"SteamGameId": "11151176786366644224"}):
|
|
os.environ.pop("BL_DESKTOP", None)
|
|
self.assertEqual(BNET["proton_options"](), ["--game-id", "11151176786366644224"])
|
|
os.environ["SteamGameId"] = "0"
|
|
self.assertEqual(BNET["proton_options"](), [])
|
|
|
|
def test_on_the_desktop_battle_net_restarts_on_the_gpu_unless_turned_off(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
gpu = os.path.join(tmp, "droiddeck-gpu")
|
|
with open(gpu, "w") as out:
|
|
out.write("#!/bin/sh\n")
|
|
os.chmod(gpu, 0o755)
|
|
with mock.patch.dict(BNET, {"BIN": tmp}), mock.patch.dict(os.environ, {"BL_DESKTOP": "1"}):
|
|
os.environ.pop("BL_DESKTOP_GPU", None)
|
|
os.environ.pop("BL_DESKTOP_GPU_CHILD", None)
|
|
self.assertEqual(BNET["desktop_gpu"](), gpu)
|
|
for choice in ("off", "0", "No", "false"):
|
|
os.environ["BL_DESKTOP_GPU"] = choice
|
|
self.assertIsNone(BNET["desktop_gpu"]())
|
|
os.environ["BL_DESKTOP_GPU"] = "on"
|
|
os.environ["BL_DESKTOP_GPU_CHILD"] = "1"
|
|
self.assertIsNone(BNET["desktop_gpu"]())
|
|
os.environ.pop("BL_DESKTOP_GPU_CHILD")
|
|
os.environ["BL_DESKTOP"] = "0"
|
|
self.assertIsNone(BNET["desktop_gpu"]())
|
|
os.environ["BL_DESKTOP"] = "1"
|
|
os.chmod(gpu, 0o644)
|
|
self.assertIsNone(BNET["desktop_gpu"]())
|
|
|
|
def test_on_the_gpu_battle_net_fills_the_screen_instead_of_a_window(self):
|
|
with mock.patch.dict(os.environ, {"BL_DESKTOP": "1", "BL_DESKTOP_GPU_CHILD": "1", "BL_WIDTH": "1280", "BL_HEIGHT": "720", "SteamGameId": "0"}):
|
|
self.assertEqual(BNET["proton_options"](), ["--desktop", "1280x720"])
|
|
|
|
def test_under_gamescope_battle_net_gets_a_desktop_of_the_screens_size(self):
|
|
with mock.patch.dict(os.environ, {"BL_WIDTH": "1280", "BL_HEIGHT": "720", "SteamGameId": "0"}):
|
|
os.environ.pop("BL_DESKTOP", None)
|
|
self.assertEqual(BNET["proton_options"](), ["--desktop", "1280x720"])
|
|
os.environ["BL_DESKTOP"] = "1"
|
|
self.assertEqual(BNET["proton_options"](), ["--window"])
|
|
|
|
def test_the_library_comes_before_the_action(self):
|
|
seen = []
|
|
self.addCleanup(BNET.__setitem__, "LIBRARY", None)
|
|
with mock.patch.dict(BNET, {"launch": lambda root, code: seen.append((BNET["LIBRARY"], code)) or 0}):
|
|
self.assertEqual(BNET["main"](["--library", "/root/Games/Stores/Battle.net", "launch", "WTCG"]), 0)
|
|
self.assertEqual(BNET["main"](["--library", "Games", "launch", "WTCG"]), 2)
|
|
self.assertEqual(seen, [("/root/Games/Stores/Battle.net", "WTCG")])
|
|
|
|
def test_only_a_code_is_launched(self):
|
|
with mock.patch.dict(BNET, {"launch": lambda root, code: self.calls.append(("launch", code)) or 0}):
|
|
for argv in (["launch"], ["launch", "Fen;rm -rf /"], ["launch", "Fen", "x"], ["open", "x"], ["bogus"]):
|
|
with self.subTest(argv):
|
|
self.assertEqual(BNET["main"](argv), 2)
|
|
self.assertEqual(BNET["main"](["launch", "Fen"]), 0)
|
|
self.assertEqual(self.calls, [("launch", "Fen")])
|
|
self.assertEqual(os.environ.get("BL_BATTLENET"), "1")
|
|
|
|
|
|
class InstallGameTest(Harness):
|
|
def setUp(self):
|
|
super().setUp()
|
|
BNET_PATCH = mock.patch.dict(BNET, {
|
|
"open_client": lambda root, reinstall=False: self.calls.append(("open",)) or 0,
|
|
"hand_over": lambda root, args, wait=None: self.calls.append(("hand_over", list(args))),
|
|
"show": lambda root, wait=None, poll=None, args=(): self.calls.append(("show", list(args))),
|
|
})
|
|
BNET_PATCH.start()
|
|
self.addCleanup(BNET_PATCH.stop)
|
|
thread = mock.patch.object(BNET["threading"], "Thread", lambda target, args, daemon: mock.Mock(start=lambda: target(*args)))
|
|
thread.start()
|
|
self.addCleanup(thread.stop)
|
|
|
|
def test_the_open_battle_net_is_asked(self):
|
|
self.install()
|
|
self.states = [self.clients() + [(30, self.AGENT)]]
|
|
self.assertEqual(BNET["install_game"](self.root, "fenris"), 0)
|
|
self.assertEqual(self.calls, [("spawn", "Battle.net.exe", "run", ["--install", "--game=fenris"])])
|
|
self.assertFalse(self.lock.closed)
|
|
|
|
def test_an_open_battle_net_without_its_agent_is_asked_once_it_has_one(self):
|
|
self.install()
|
|
self.states = [self.clients()]
|
|
with mock.patch.dict(BNET, {"acquire": lambda folder=None: None}):
|
|
self.assertEqual(BNET["install_game"](self.root, "fenris"), 0)
|
|
self.assertEqual(self.calls, [("show", ["--install", "--game=fenris"])])
|
|
|
|
def test_battle_net_is_opened_and_asked_once_it_is_up(self):
|
|
self.install()
|
|
self.assertEqual(BNET["install_game"](self.root, "osi"), 0)
|
|
self.assertEqual(self.calls, [("hand_over", ["--install", "--game=osi"]), ("open",)])
|
|
|
|
def test_without_battle_net_it_is_installed_first_then_asked(self):
|
|
self.assertEqual(BNET["install_game"](self.root, "hs_beta"), 0)
|
|
self.assertEqual(self.calls, [("hand_over", ["--install", "--game=hs_beta"]), ("open",)])
|
|
|
|
def test_another_start_holding_the_lock_hands_it_on(self):
|
|
with mock.patch.dict(BNET, {"acquire": lambda folder=None: None}):
|
|
self.assertEqual(BNET["install_game"](self.root, "prometheus"), 0)
|
|
self.assertEqual(self.calls, [("show", ["--install", "--game=prometheus"])])
|
|
|
|
def test_only_an_id_is_asked_for(self):
|
|
with mock.patch.dict(BNET, {"install_game": lambda root, uid: self.calls.append(("get", uid)) or 0}):
|
|
for argv in (["install-game"], ["install-game", "Fenris"], ["install-game", "osi;rm"], ["install-game", "osi", "x"]):
|
|
with self.subTest(argv):
|
|
self.assertEqual(BNET["main"](argv), 2)
|
|
self.assertEqual(BNET["main"](["install-game", "hs_beta"]), 0)
|
|
self.assertEqual(self.calls, [("get", "hs_beta")])
|
|
|
|
|
|
class HandOverTest(Harness):
|
|
def test_once_battle_net_is_up_it_is_asked(self):
|
|
self.install()
|
|
self.states = [[], self.clients()]
|
|
self.assertTrue(BNET["hand_over"](self.root, ["--install", "--game=osi"], wait=60))
|
|
self.assertEqual(self.calls, [("spawn", "Battle.net.exe", "run", ["--install", "--game=osi"])])
|
|
|
|
def test_a_battle_net_that_never_comes_up_is_not_asked(self):
|
|
self.install()
|
|
self.states = [[]]
|
|
self.assertFalse(BNET["hand_over"](self.root, ["--install", "--game=osi"], wait=0))
|
|
self.assertEqual(self.calls, [])
|
|
|
|
|
|
class AgentTest(Temp):
|
|
def test_the_newest_agent_build_and_its_port_are_found(self):
|
|
root = str(self.tmp / "Steam")
|
|
agent = "Steam/steamapps/compatdata/%s/pfx/drive_c/ProgramData/Battle.net/Agent" % BNET["APP_ID"]
|
|
self.write(agent + "/Agent.9100/Agent.exe", b"MZ")
|
|
self.write(agent + "/Agent.9824/Agent.exe", b"MZ")
|
|
os.makedirs(self.tmp / agent / "Agent.9999")
|
|
self.write(agent + "/Agent.dat", "39211\n")
|
|
self.assertTrue(BNET["agent_exe"](root).endswith("Agent.9824/Agent.exe"))
|
|
self.assertEqual(BNET["agent_port"](root), 39211)
|
|
self.write(agent + "/Agent.dat", "not a port")
|
|
self.assertIsNone(BNET["agent_port"](root))
|
|
|
|
def test_progress_keeps_the_fields_the_app_reads(self):
|
|
status = {"progress": 0.35, "download_current": [2397, 0, 0], "download_total": [6746, 0, 0], "download_rate": 34076920,
|
|
"state": 1003, "build_config_kv_pairs": {"x": "y"}, "regional_version_info": {}, "estimated_time_in_sec": 120,
|
|
"extended_status": {"current": 1, "state": 500, "total": 2, "unit_type": "bytes"}}
|
|
self.assertEqual(BNET["summary"](status), {"progress": 0.35, "download_current": 2397, "download_total": 6746,
|
|
"download_rate": 34076920, "state": 1003, "estimated_time_in_sec": 120, "phase": 500})
|
|
|
|
def test_an_unknown_time_left_is_not_reported(self):
|
|
for eta in (18446744073709551615, -1, 1.5):
|
|
with self.subTest(eta):
|
|
self.assertNotIn("estimated_time_in_sec", BNET["summary"]({"estimated_time_in_sec": eta}))
|
|
|
|
def test_the_driver_script_reads_its_arguments_in_the_order_they_are_passed(self):
|
|
script = BNET["AGENT_SCRIPT"]
|
|
self.assertIn("port = a(0), action = a(1), uid = a(2), product = a(3), dir = a(4)", script)
|
|
self.assertIn("lang = a(5), country = a(6), region = a(7), out = a(8)", script)
|
|
self.assertIn('"--internalclienttools"', (BIN / "droiddeck-battlenet").read_text())
|
|
|
|
def test_the_driver_asks_blizzards_chinese_repository_for_chinese_installs(self):
|
|
script = BNET["AGENT_SCRIPT"]
|
|
self.assertIn('var patch = zone == "cn" ? "cn.patch.battlenet.com.cn" : zone + ".patch.battle.net";', script)
|
|
self.assertIn("+ patch + ':1119/'", script)
|
|
|
|
|
|
class AgentJobTest(Harness):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.lines = []
|
|
self.files = {}
|
|
self.started = []
|
|
self.envs = []
|
|
test = self
|
|
|
|
class Child:
|
|
def __init__(self, cmd, **kwargs):
|
|
test.started.append(cmd)
|
|
test.envs.append(kwargs.get("env") or {})
|
|
base = cmd[-1][2:].replace("\\", "/")
|
|
for suffix, value in test.files.items():
|
|
with open(base + suffix, "wb") as f:
|
|
f.write(value.encode("utf-16"))
|
|
self.returncode = 0
|
|
|
|
def poll(self):
|
|
return 0
|
|
|
|
patcher = mock.patch.dict(BNET, {"emit": lambda kind, value="": self.lines.append((kind, value)),
|
|
"stop_leftovers": lambda pfx: True, "CACHE": str(self.tmp / "cache")})
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
popen = mock.patch.object(BNET["subprocess"], "Popen", Child)
|
|
popen.start()
|
|
self.addCleanup(popen.stop)
|
|
|
|
def install(self):
|
|
super().install()
|
|
self.write("Steam/steamapps/compatdata/%s/pfx/drive_c/ProgramData/Battle.net/Agent/Agent.9824/Agent.exe" % BNET["APP_ID"], b"MZ")
|
|
|
|
def test_a_game_downloads_headless_and_reports_where_it_landed(self):
|
|
self.install()
|
|
folder = str(self.tmp / "Games/Battle.net/Warcraft Rumble")
|
|
self.files = {
|
|
".json": json.dumps({"progress": 1.0, "download_complete": True, "state": 1004, "download_current": [6746, 0, 0],
|
|
"download_total": [6746, 0, 0]}),
|
|
".game": json.dumps({"installed": True, "install_dir": "Z:" + folder, "local_version": "16.1.2.119725",
|
|
"binaries": {"game": {"relative_path": "WarcraftRumble.exe"}}}),
|
|
}
|
|
code = BNET["agent_job"](self.root, "install", "gryphon", "gryphon", folder, "enUS", "USA", "US", poll=0)
|
|
self.assertEqual(code, 0)
|
|
self.assertTrue(os.path.isdir(folder))
|
|
cmd = self.started[0]
|
|
self.assertEqual(cmd[:6], ["gamescope", "--backend", "headless", "-w", "640", "-h"])
|
|
self.assertEqual(cmd[cmd.index("--") + 2:-1], ["agent-serve", "install", "gryphon", "gryphon", "Z:" + folder, "enUS", "USA", "US"])
|
|
self.assertEqual([kind for kind, _ in self.lines], ["STATE", "PROGRESS", "DONE"])
|
|
self.assertEqual(json.loads(self.lines[-1][1]), {"install_dir": "Z:" + folder, "exe": "WarcraftRumble.exe", "version": "16.1.2.119725"})
|
|
|
|
def test_an_agent_error_is_reported_with_its_code(self):
|
|
self.install()
|
|
self.files = {".json": json.dumps({"error": 2421, "progress": 0.0})}
|
|
self.assertEqual(BNET["agent_job"](self.root, "install", "fenris", "fenris", str(self.tmp / "D4"), "enUS", "USA", "US", poll=0), 5)
|
|
self.assertEqual(self.lines[-1], ("ERROR", "agent 2421"))
|
|
|
|
def test_a_driver_failure_is_reported_with_its_reason(self):
|
|
self.install()
|
|
self.files = {".error": "the agent stopped answering"}
|
|
self.assertEqual(BNET["agent_job"](self.root, "update", "wow", poll=0), 1)
|
|
self.assertEqual(self.lines[-1], ("ERROR", "stopped the agent stopped answering"))
|
|
|
|
def test_an_update_reports_the_new_version(self):
|
|
self.install()
|
|
self.files = {".json": json.dumps({"progress": 1.0}),
|
|
".game": json.dumps({"installed": True, "playable": True, "install_dir": "Z:/g", "local_version": "2"})}
|
|
self.assertEqual(BNET["agent_job"](self.root, "update", "gryphon", poll=0), 0)
|
|
self.assertIn("agent-serve", self.started[0])
|
|
self.assertEqual(json.loads(self.lines[-1][1])["version"], "2")
|
|
|
|
def test_a_game_left_unplayable_is_not_reported_as_done(self):
|
|
self.install()
|
|
self.files = {".json": json.dumps({"state": 1008, "download_complete": True, "progress": 0.0}),
|
|
".game": json.dumps({"installed": True, "playable": False, "install_dir": "Z:/g", "local_version": "2"})}
|
|
self.assertEqual(BNET["agent_job"](self.root, "update", "gryphon", poll=0), 1)
|
|
self.assertEqual(self.lines[-1][0], "ERROR")
|
|
|
|
def test_the_driver_only_stops_once_the_agent_has_really_run(self):
|
|
script = BNET["AGENT_SCRIPT"]
|
|
self.assertIn("if (started && (state == 1004 || complete && /\"playable\"\\s*:\\s*true/.test(s))) break;", script)
|
|
self.assertNotIn('"progress"', script)
|
|
|
|
def test_an_uninstall_is_done_once_the_agent_no_longer_lists_it(self):
|
|
self.install()
|
|
self.files = {".json": json.dumps({}), ".game": json.dumps({"installed": False})}
|
|
self.assertEqual(BNET["agent_job"](self.root, "uninstall", "gryphon", poll=0), 0)
|
|
self.assertEqual(self.lines[-1], ("DONE", json.dumps({"uninstalled": "gryphon"})))
|
|
|
|
def test_a_gamescope_that_cannot_start_is_reported(self):
|
|
self.install()
|
|
|
|
def missing(cmd, **kwargs):
|
|
raise FileNotFoundError("gamescope")
|
|
|
|
with mock.patch.object(BNET["subprocess"], "Popen", missing):
|
|
self.assertEqual(BNET["agent_job"](self.root, "update", "gryphon", poll=0), 1)
|
|
self.assertEqual(self.lines[-1], ("ERROR", "stopped gamescope: gamescope"))
|
|
|
|
def test_an_agent_answer_that_is_not_an_object_is_ignored(self):
|
|
path = self.tmp / "answer.json"
|
|
for value in ([1, 2], "text", 3, None):
|
|
with self.subTest(value):
|
|
path.write_bytes(json.dumps(value).encode("utf-16"))
|
|
self.assertIsNone(BNET["read_status"](str(path)))
|
|
path.write_bytes(json.dumps({"state": 1004}).encode("utf-8"))
|
|
self.assertEqual(BNET["read_status"](str(path)), {"state": 1004})
|
|
|
|
def test_a_large_agent_log_is_rotated_before_a_job(self):
|
|
self.install()
|
|
os.makedirs(self.tmp / "cache")
|
|
log = self.tmp / "cache" / "agent.log"
|
|
log.write_bytes(b"x" * (BNET["AGENT_LOG_LIMIT"] + 1))
|
|
self.files = {".game": json.dumps({"installed": False})}
|
|
BNET["agent_job"](self.root, "uninstall", "gryphon", poll=0)
|
|
self.assertEqual((self.tmp / "cache" / "agent.log.1").stat().st_size, BNET["AGENT_LOG_LIMIT"] + 1)
|
|
self.assertEqual(log.stat().st_size, 0)
|
|
|
|
def test_it_queues_behind_another_battle_net_job(self):
|
|
self.install()
|
|
self.files = {".game": json.dumps({"installed": False})}
|
|
locks = iter([None, contextlib.nullcontext()])
|
|
with mock.patch.dict(BNET, {"acquire": lambda *a: next(locks)}):
|
|
self.assertEqual(BNET["agent_job"](self.root, "uninstall", "gryphon", poll=0), 0)
|
|
self.assertEqual(self.lines[:2], [("STATE", "queued"), ("STATE", "starting")])
|
|
|
|
def test_an_uninstall_needs_the_agent_to_say_the_game_is_gone(self):
|
|
self.install()
|
|
self.files = {".game": json.dumps({"error": 2})}
|
|
self.assertEqual(BNET["agent_job"](self.root, "uninstall", "gryphon", poll=0), 1)
|
|
self.assertEqual(self.lines[-1][0], "ERROR")
|
|
self.files = {".game": json.dumps({})}
|
|
self.assertEqual(BNET["agent_job"](self.root, "uninstall", "gryphon", poll=0), 0)
|
|
self.assertEqual(self.lines[-1], ("DONE", json.dumps({"uninstalled": "gryphon"})))
|
|
|
|
def test_without_battle_net_nothing_starts(self):
|
|
self.assertEqual(BNET["agent_job"](self.root, "install", "gryphon", "gryphon", str(self.tmp / "g"), "enUS", "USA", "US", poll=0), 3)
|
|
self.assertEqual(self.lines, [("ERROR", "client")])
|
|
self.assertEqual(self.started, [])
|
|
|
|
def test_it_waits_while_battle_net_or_a_game_is_open(self):
|
|
self.install()
|
|
self.states = [self.clients() + [(40, self.GAME)], [(40, self.GAME)], []]
|
|
self.files = {".game": json.dumps({"installed": False})}
|
|
self.assertEqual(BNET["agent_job"](self.root, "uninstall", "gryphon", poll=0), 0)
|
|
self.assertEqual(self.lines[0], ("STATE", "waiting"))
|
|
self.assertEqual(self.lines[1], ("STATE", "starting"))
|
|
|
|
def test_gamescope_gets_no_display_but_the_agent_keeps_the_one_gamescope_gives_it(self):
|
|
self.install()
|
|
os.environ.update(DISPLAY=":5", WAYLAND_DISPLAY="wayland-1")
|
|
self.files = {".game": json.dumps({"installed": False})}
|
|
BNET["agent_job"](self.root, "uninstall", "gryphon", poll=0)
|
|
self.assertNotIn("DISPLAY", self.envs[0])
|
|
self.assertNotIn("WAYLAND_DISPLAY", self.envs[0])
|
|
self.assertEqual(self.envs[0]["PROTON_USE_XALIA"], "0")
|
|
os.environ["DISPLAY"] = ":0"
|
|
self.assertEqual(BNET["agent_env"]()["DISPLAY"], ":0")
|
|
|
|
def test_only_valid_arguments_start_a_job(self):
|
|
calls = []
|
|
with mock.patch.dict(BNET, {"agent_job": lambda *a, **k: calls.append(a[1:]) or 0}):
|
|
for argv in (["agent-install", "gryphon", "gryphon", "relative/dir", "enUS", "USA", "US"],
|
|
["agent-install", "gryphon", "Gry phon", "/g", "enUS", "USA", "US"],
|
|
["agent-install", "gryphon", "gryphon", "/g", "english", "USA", "US"],
|
|
["agent-update"], ["agent-uninstall", "Gryphon"]):
|
|
with self.subTest(argv):
|
|
self.assertEqual(BNET["main"](argv), 2)
|
|
self.assertEqual(BNET["main"](["agent-install", "gryphon", "gryphon", "/g/../g/Rumble", "enUS", "USA", "US"]), 0)
|
|
self.assertEqual(BNET["main"](["agent-update", "wow_classic"]), 0)
|
|
self.assertEqual(calls, [("install", "gryphon", "gryphon", "/g/Rumble", "enUS", "USA", "US"), ("update", "wow_classic")])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|