Files
DroidDeck/tools/tests/test_droiddeck_agent.py
MaxsTechReview 6bcd431ca6 fix(session): wait for Steam readiness before launch (#2362)
* fix(session): wait for Steam readiness before launch

* fix(session): keep the launch fallback when Steam readiness or the interstitial is unavailable

* fix(session): cap the Steam readiness wait by elapsed time and bound each check

* fix(session): keep waiting for Steam readiness through focus changes and never pass a zero timeout
2026-11-09 18:23:24 -05:00

901 lines
46 KiB
Python

import base64
import hashlib
import importlib.machinery
import importlib.util
import json
import os
from pathlib import Path
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
import tracemalloc
SCRIPT = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-agent"
loader = importlib.machinery.SourceFileLoader("droiddeck_agent", str(SCRIPT))
spec = importlib.util.spec_from_loader(loader.name, loader)
agent = importlib.util.module_from_spec(spec)
loader.exec_module(agent)
class FakeDevTools(threading.Thread):
def __init__(self, answer):
super().__init__(daemon=True)
self.answer = answer
self.server = socket.socket()
self.server.bind(("127.0.0.1", 0))
self.server.listen(4)
self.port = self.server.getsockname()[1]
self.seen = []
def run(self):
while True:
try:
conn, _ = self.server.accept()
except OSError:
return
threading.Thread(target=self.serve, args=(conn,), daemon=True).start()
def serve(self, conn):
head = b""
while b"\r\n\r\n" not in head:
head += conn.recv(4096)
request = head.decode()
if request.startswith("GET /json"):
body = json.dumps([{"title": "SharedJSContext", "url": "https://steamloopback.host/", "type": "page",
"webSocketDebuggerUrl": "ws://127.0.0.1:%d/devtools/page/1" % self.port}]).encode()
conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + body)
conn.close()
return
key = [l.split(":", 1)[1].strip() for l in request.split("\r\n") if l.lower().startswith("sec-websocket-key")][0]
accept = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
conn.sendall(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Accept: %s\r\n\r\n" % accept).encode())
b0, b1 = conn.recv(2)
n = b1 & 0x7F
if n == 126:
n = struct.unpack(">H", conn.recv(2))[0]
mask = conn.recv(4)
data = b""
while len(data) < n:
data += conn.recv(n - len(data))
message = json.loads(bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
self.seen.append(message)
for reply in ({"method": "Runtime.consoleAPICalled", "params": {}}, dict(self.answer, id=message["id"])):
payload = json.dumps(reply).encode()
header = bytes([0x81, 126]) + struct.pack(">H", len(payload)) if len(payload) > 125 else bytes([0x81, len(payload)])
conn.sendall(header + payload)
time.sleep(0.2)
conn.close()
class FakeSteamClient(threading.Thread):
def __init__(self, showing):
super().__init__(daemon=True)
self.showing = showing
self.server = socket.socket()
self.server.bind(("127.0.0.1", 0))
self.server.listen(4)
self.port = self.server.getsockname()[1]
self.seen = []
self.conn = None
self.lock = threading.Lock()
def run(self):
while True:
try:
conn, _ = self.server.accept()
except OSError:
return
threading.Thread(target=self.serve, args=(conn,), daemon=True).start()
def send(self, conn, message):
payload = json.dumps(message).encode()
header = bytes([0x81, 126]) + struct.pack(">H", len(payload)) if len(payload) > 125 else bytes([0x81, len(payload)])
with self.lock:
conn.sendall(header + payload)
def push(self, payload, name=None):
self.send(self.conn, {"method": "Runtime.bindingCalled",
"params": {"name": name or agent.KEYBOARD_BINDING, "payload": payload}})
def drop(self):
self.conn.shutdown(socket.SHUT_RDWR)
self.conn.close()
def serve(self, conn):
stream = conn.makefile("rb")
head = b""
while not head.endswith(b"\r\n\r\n"):
head += stream.read(1)
request = head.decode()
if request.startswith("GET /json"):
body = json.dumps([{"title": "Steam Big Picture Mode", "url": "https://steamloopback.host/routes/",
"webSocketDebuggerUrl": "ws://127.0.0.1:%d/devtools/page/2" % self.port},
{"title": "SharedJSContext", "url": "https://steamloopback.host/",
"webSocketDebuggerUrl": "ws://127.0.0.1:%d/devtools/page/1" % self.port}]).encode()
conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + body)
conn.close()
return
key = [l.split(":", 1)[1].strip() for l in request.split("\r\n") if l.lower().startswith("sec-websocket-key")][0]
accept = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
conn.sendall(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Accept: %s\r\n\r\n" % accept).encode())
self.conn = conn
try:
while True:
b0, b1 = stream.read(2)
n = b1 & 0x7F
if n == 126:
n = struct.unpack(">H", stream.read(2))[0]
elif n == 127:
n = struct.unpack(">Q", stream.read(8))[0]
mask = stream.read(4)
data = stream.read(n)
message = json.loads(bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
self.seen.append(message)
self.push("true", name="unrelated")
result = {"result": {"type": "boolean", "value": self.showing}} if message["method"] == "Runtime.evaluate" else {}
self.send(conn, {"id": message["id"], "result": result})
except (OSError, ValueError):
conn.close()
class AgentTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.dir = Path(self.temp.name)
(self.dir / "resp").mkdir()
def ask(self, request):
path = self.dir / "req.json"
path.write_text(json.dumps(request))
agent.answer(self.dir, path)
self.assertFalse(path.exists(), "the request file is consumed")
return json.loads((self.dir / "resp" / "req.json").read_text())
def vr_helper(self, body):
helper = self.dir / "droiddeck-steamvr"
helper.write_text("#!/bin/sh\n" + body)
old = os.environ.get("DROIDDECK_VR_HELPER")
os.environ["DROIDDECK_VR_HELPER"] = str(helper)
self.addCleanup(lambda: os.environ.pop("DROIDDECK_VR_HELPER") if old is None
else os.environ.__setitem__("DROIDDECK_VR_HELPER", old))
return helper
def test_vr_status_is_parsed(self):
self.vr_helper('echo "installed=1 running=0 installing=0 driver=1"\n')
answer = self.ask({"kind": "vr", "action": "status"})
self.assertTrue(answer["ok"])
self.assertEqual((True, False, False, True),
(answer["installed"], answer["running"], answer["installing"], answer["driver"]))
def test_vr_runs_only_its_own_actions(self):
log = self.dir / "args"
self.vr_helper('echo "$@" > %s\n' % log)
self.assertTrue(self.ask({"kind": "vr", "action": "start"})["ok"])
self.assertEqual("start", log.read_text().strip())
answer = self.ask({"kind": "vr", "action": "rm -rf /"})
self.assertFalse(answer["ok"])
self.assertEqual("INVALID_REQUEST", answer["error"]["code"])
def test_vr_without_a_helper_is_unavailable(self):
os.environ["DROIDDECK_VR_HELPER"] = str(self.dir / "missing")
self.addCleanup(os.environ.pop, "DROIDDECK_VR_HELPER")
answer = self.ask({"kind": "vr", "action": "status"})
self.assertEqual("UNAVAILABLE", answer["error"]["code"])
def fake_proc(self, pid, parent, comm, env):
root = self.dir / "proc" / str(pid)
root.mkdir(parents=True)
(root / "comm").write_text(comm + "\n")
(root / "stat").write_text("%d (%s) S %d 0 0" % (pid, comm, parent))
(root / "environ").write_bytes(b"\0".join(b"%s=%s" % (k.encode(), v.encode()) for k, v in env.items()) + b"\0")
def tag(self, wins, arch="arm64"):
snap = {"displays": {":0": [], ":1": wins}}
with patch.dict(os.environ, {"DISPLAY": ":0", "BL_STEAM_CLIENT_ARCH": arch}), \
patch.object(agent.subprocess, "run") as run, patch.object(agent, "log"):
run.return_value.returncode = 0
tagged = agent.tag_game_windows(snap, proc=self.dir / "proc")
return tagged, [call.args[0][-1] for call in run.call_args_list]
def test_a_sandboxed_flatpak_window_is_tagged_with_its_shortcut(self):
self.fake_proc(40, 1, "gjs", {"FLATPAK_ID": "com.github.tchx84.Flatseal", "SteamAppId": "2166984148"})
win = {"window": 7, "map": "viewable", "class": "com.github.tchx84.Flatseal", "pid": None}
self.assertEqual(self.tag([win]), (True, ["2166984148"]))
def test_a_flatpak_window_named_after_the_app_is_matched(self):
self.fake_proc(40, 1, "vacuumtube", {"FLATPAK_ID": "rocks.shy.VacuumTube", "SteamAppId": "3000000001"})
win = {"window": 7, "map": "viewable", "class": "VacuumTube", "pid": None}
self.assertEqual(self.tag([win]), (True, ["3000000001"]))
def test_a_window_whose_process_has_no_app_id_falls_back_to_its_flatpak(self):
self.fake_proc(40, 1, "bwrap", {"FLATPAK_ID": "com.github.tchx84.Flatseal", "SteamAppId": "2166984148"})
self.fake_proc(41, 1, "gjs", {})
win = {"window": 7, "map": "viewable", "class": "com.github.tchx84.Flatseal", "pid": 41}
self.assertEqual(self.tag([win]), (True, ["2166984148"]))
def test_a_flatpak_shared_by_two_shortcuts_is_not_guessed(self):
self.fake_proc(40, 1, "gjs", {"FLATPAK_ID": "com.github.tchx84.Flatseal", "SteamAppId": "2166984148"})
self.fake_proc(42, 1, "gjs", {"FLATPAK_ID": "com.github.tchx84.Flatseal", "SteamAppId": "3000000003"})
win = {"window": 7, "map": "viewable", "class": "com.github.tchx84.Flatseal", "pid": None}
self.assertEqual(self.tag([win]), (False, []))
def test_a_window_outside_the_reaper_takes_its_process_app_id(self):
self.fake_proc(30, 1, "proot", {})
self.fake_proc(31, 30, "electron", {"SteamAppId": "3000000002"})
win = {"window": 8, "map": "viewable", "class": "electron", "pid": 31}
self.assertEqual(self.tag([win]), (True, ["3000000002"]))
def test_windows_gamescope_already_resolves_are_left_alone(self):
self.fake_proc(20, 1, "reaper", {})
self.fake_proc(21, 20, "game", {"SteamAppId": "413150"})
wins = [{"window": 9, "map": "viewable", "class": "game", "pid": 21},
{"window": 10, "map": "viewable", "class": "tagged", "pid": None, "appId": 5},
{"window": 11, "map": "viewable", "class": "other", "pid": None},
{"window": 12, "map": "unmapped", "class": "game", "pid": 21},
{"window": 13, "map": "viewable", "class": "steam_app_550", "pid": None}]
self.assertEqual(self.tag(wins), (False, []))
def test_the_x86_client_still_tags_its_game_classes(self):
win = {"window": 13, "map": "viewable", "class": "steam_app_550", "pid": None}
self.assertEqual(self.tag([win], arch="x86_64"), (True, ["550"]))
def test_exec_output_and_status(self):
answer = self.ask({"kind": "exec", "argv": ["sh", "-c", "echo out; echo err >&2; exit 3"], "env": {"X": "1"}})
self.assertTrue(answer["ok"])
self.assertEqual(3, answer["exitCode"])
self.assertEqual("out\n", answer["stdout"]["text"])
self.assertEqual("err\n", answer["stderr"]["text"])
self.assertFalse(answer["timedOut"])
def test_exec_environment_stdin_and_cap(self):
answer = self.ask({"kind": "exec", "argv": ["sh", "-c", "printf %s \"$X\"; cat"], "env": {"X": "a"}, "stdin": "b"})
self.assertEqual("ab", answer["stdout"]["text"])
big = self.ask({"kind": "exec", "argv": ["sh", "-c", "head -c 300000 /dev/zero"]})
self.assertTrue(big["stdout"]["truncated"])
self.assertEqual(agent.OUTPUT_CAP, len(big["stdout"]["text"]))
def test_exec_timeout_kills_the_process_group(self):
started = time.monotonic()
answer = self.ask({"kind": "exec", "argv": ["sh", "-c", "sleep 30 & sleep 30"], "timeout": 0.5})
self.assertTrue(answer["timedOut"])
self.assertLess(time.monotonic() - started, 10)
def test_exec_memory_is_bounded_while_draining_large_output(self):
tracemalloc.start()
try:
answer = agent.run_exec({"argv": [sys.executable, "-c", "import os; [os.write(1, b'x' * 65536) for _ in range(1024)]"]})
_, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
self.assertEqual(0, answer["exitCode"])
self.assertEqual(agent.OUTPUT_CAP, len(answer["stdout"]["text"]))
self.assertTrue(answer["stdout"]["truncated"])
self.assertLess(peak, 6 * 1024 * 1024)
def test_exec_drains_both_streams_while_writing_stdin(self):
program = "import os, sys; os.write(1, b'x' * 300000); print(len(sys.stdin.read()), file=sys.stderr)"
answer = self.ask({"kind": "exec", "argv": [sys.executable, "-c", program], "stdin": "y" * 100000})
self.assertFalse(answer["timedOut"])
self.assertTrue(answer["stdout"]["truncated"])
self.assertEqual("100000\n", answer["stderr"]["text"])
def test_flooding_both_streams_still_times_out(self):
program = "import os\nwhile True:\n os.write(1, b'o' * 65536)\n os.write(2, b'e' * 65536)"
answer = self.ask({"kind": "exec", "argv": [sys.executable, "-c", program], "timeout": 0.5})
self.assertTrue(answer["timedOut"])
for name in ("stdout", "stderr"):
self.assertEqual(agent.OUTPUT_CAP, len(answer[name]["text"]))
self.assertTrue(answer[name]["truncated"])
def test_timeout_applies_when_parent_exits_with_inherited_pipes(self):
started = time.monotonic()
answer = self.ask({"kind": "exec", "argv": ["sh", "-c", "sleep 30 & exit 0"], "timeout": 0.5})
self.assertTrue(answer["timedOut"])
self.assertLess(time.monotonic() - started, 5)
def test_invalid_timeout_and_input_are_refused_before_exec(self):
for value in (0, -1, 601, "NaN", "Infinity", "invalid"):
answer = self.ask({"kind": "exec", "argv": ["true"], "timeout": value})
self.assertEqual("INVALID_REQUEST", answer["error"]["code"])
for fields in ({"stdin": 1}, {"env": []}, {"env": {"BAD=KEY": "value"}}, {"argv": ["true\0"]}):
answer = self.ask({"kind": "exec", "argv": ["true"], **fields})
self.assertEqual("INVALID_REQUEST", answer["error"]["code"])
def test_errors_are_answers(self):
self.assertEqual("INVALID_REQUEST", self.ask({"kind": "exec", "argv": []})["error"]["code"])
self.assertEqual("EXEC_FAILED", self.ask({"kind": "exec", "argv": ["/no/such/program"]})["error"]["code"])
self.assertEqual("UNKNOWN_REQUEST", self.ask({"kind": "nope"})["error"]["code"])
self.assertEqual("INVALID_APP_ID", self.ask({"kind": "quit", "appId": "x"})["error"]["code"])
(self.dir / "req.json").write_text("{not json")
agent.answer(self.dir, self.dir / "req.json")
self.assertEqual("INVALID_REQUEST", json.loads((self.dir / "resp" / "req.json").read_text())["error"]["code"])
def test_request_shape_and_size_are_bounded(self):
self.assertEqual("INVALID_REQUEST", self.ask([])["error"]["code"])
self.assertEqual("INVALID_REQUEST", self.ask({"kind": "ping", "unused": "x" * agent.REQUEST_CAP})["error"]["code"])
def test_replies_are_owner_only_even_when_replacing_a_public_file(self):
path = self.dir / "private.json"
path.write_text("old")
path.chmod(0o644)
agent.write_json(path, {"ok": True})
self.assertEqual(0o600, path.stat().st_mode & 0o777)
def test_oversized_websocket_frames_are_refused_before_reading_payload(self):
ws = agent.WebSocket.__new__(agent.WebSocket)
ws.buffer = bytes([0x81, 127]) + struct.pack(">Q", agent.RESPONSE_CAP + 1)
with self.assertRaises(agent.AgentError) as caught:
ws.recv()
self.assertEqual("RESPONSE_TOO_LARGE", caught.exception.code)
def test_devtools_targets_cannot_connect_to_remote_hosts(self):
for url in ("ws://example.com/devtools", "ws://127.0.0.1@example.com/devtools", "wss://127.0.0.1/devtools"):
with patch.object(agent.socket, "create_connection") as connect:
with self.assertRaises(agent.AgentError) as caught:
agent.WebSocket(url, 1)
self.assertEqual("INVALID_TARGET", caught.exception.code)
connect.assert_not_called()
def test_cdp_without_a_port(self):
os.environ.pop("BL_CDP_PORT", None)
self.assertEqual("NO_DEVTOOLS", self.ask({"kind": "cdp", "expression": "1"})["error"]["code"])
def test_cdp_eval(self):
server = FakeDevTools({"result": {"result": {"type": "number", "value": 42}}})
server.start()
self.addCleanup(server.server.close)
(self.dir / "cdp-port").write_text("%d\n" % server.port)
answer = self.ask({"kind": "cdp", "expression": "6 * 7"})
self.assertTrue(answer["ok"], answer)
self.assertEqual(42, answer["value"])
self.assertEqual("SharedJSContext", answer["target"])
self.assertEqual("Runtime.evaluate", server.seen[0]["method"])
self.assertEqual("6 * 7", server.seen[0]["params"]["expression"])
targets = self.ask({"kind": "cdp", "op": "targets"})
self.assertEqual("SharedJSContext", targets["targets"][0]["title"])
missing = self.ask({"kind": "cdp", "expression": "1", "target": "Nope"})
self.assertEqual("NO_TARGET", missing["error"]["code"])
def test_steam_selection(self):
with patch.object(agent, "cdp_eval", return_value={"value": "3180310"}) as evaluate:
answer = self.ask({"kind": "steam-selection"})
self.assertEqual(3180310, answer["appId"])
self.assertIn("document.activeElement", evaluate.call_args.args[1])
self.assertEqual("Steam Big Picture Mode", evaluate.call_args.kwargs["target"])
def test_steam_selection_allows_no_highlight(self):
with patch.object(agent, "cdp_eval", return_value={"value": None}):
self.assertIsNone(self.ask({"kind": "steam-selection"})["appId"])
def test_steam_selection_rejects_invalid_app_id(self):
with patch.object(agent, "cdp_eval", return_value={"value": "4294967296"}):
answer = self.ask({"kind": "steam-selection"})
self.assertEqual("INVALID_SELECTION", answer["error"]["code"])
def test_shortcut_cgameid_waits_for_exact_steam_overview_mapping(self):
game_id = str((3551182975 << 32) | 0x02000000)
self.assertEqual(3551182975, agent.steam_shortcut_app_id(game_id))
self.assertIsNone(agent.steam_shortcut_app_id("413150"))
self.assertIsNone(agent.steam_shortcut_app_id(str((3551182975 << 32) | 0x03000000)))
clock = [0.0]
with patch.object(agent, "cdp_eval", side_effect=[{"value": None}, {"value": "3551182975"}]) as evaluate, \
patch.object(agent.time, "monotonic", side_effect=lambda: clock[0]), \
patch.object(agent.time, "sleep", side_effect=lambda delay: clock.__setitem__(0, clock[0] + delay)):
result = agent.wait_for_steam_shortcut_overview(self.dir, game_id, timeout=1)
self.assertEqual({"supported": True, "ready": True, "appId": 3551182975}, result)
expression = evaluate.call_args_list[0].args[1]
self.assertIn("GetAppOverviewByGameID", expression)
self.assertIn(json.dumps(game_id), expression)
self.assertNotIn("RequestAppDetails", expression)
self.assertFalse(evaluate.call_args_list[0].kwargs["await_promise"])
def test_wrong_overview_app_id_keeps_shortcut_url_held_until_timeout(self):
game_id = str((3537741571 << 32) | 0x02000000)
clock = [0.0]
with patch.object(agent, "cdp_eval", return_value={"value": "646570"}) as evaluate, \
patch.object(agent.time, "monotonic", side_effect=lambda: clock[0]), \
patch.object(agent.time, "sleep", side_effect=lambda delay: clock.__setitem__(0, clock[0] + delay)):
with self.assertRaises(agent.AgentError) as raised:
agent.wait_for_steam_shortcut_overview(self.dir, game_id, timeout=0.5)
self.assertEqual("STEAM_OVERVIEW_TIMEOUT", raised.exception.code)
self.assertGreater(evaluate.call_count, 0)
self.assertTrue(all("RequestAppDetails" not in call.args[1] for call in evaluate.call_args_list))
def test_normal_steam_ids_and_unknown_cgameids_skip_overview_wait(self):
for game_id in ("413150", str((123456789 << 32) | 0x03000000), "18446744073709551616"):
with self.subTest(game_id=game_id), patch.object(agent, "cdp_eval") as evaluate:
result = agent.wait_for_steam_shortcut_overview(self.dir, game_id)
self.assertFalse(result["supported"])
evaluate.assert_not_called()
def test_shortcut_state_tells_a_lost_shortcut_from_a_loading_library(self):
game_id = str((3333273269 << 32) | 0x02000000)
self.assertEqual("not-shortcut", agent.steam_shortcut_state(self.dir, "413150"))
for values, wanted, calls in ((["loading", "ready"], "ready", 2), (["loading", "missing"], "missing", 3),
([None], "loading", 3)):
clock = [0.0]
replies = [{"value": v} for v in values] + [{"value": values[-1]}] * 10
with self.subTest(values=values), patch.object(agent, "cdp_eval", side_effect=replies) as evaluate, \
patch.object(agent.time, "monotonic", side_effect=lambda: clock[0]), \
patch.object(agent.time, "sleep", side_effect=lambda delay: clock.__setitem__(0, clock[0] + delay)):
self.assertEqual(wanted, agent.steam_shortcut_state(self.dir, game_id, timeout=1))
self.assertEqual(calls, evaluate.call_count)
self.assertIn("GetAppOverviewByGameID(%s)" % json.dumps(game_id), evaluate.call_args_list[0].args[1])
def test_shortcut_state_waits_out_an_unreachable_client_but_not_other_errors(self):
game_id = str((3333273269 << 32) | 0x02000000)
clock = [0.0]
with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("NO_DEVTOOLS", "down")), \
patch.object(agent.time, "monotonic", side_effect=lambda: clock[0]), \
patch.object(agent.time, "sleep", side_effect=lambda delay: clock.__setitem__(0, clock[0] + delay)):
self.assertEqual("loading", agent.steam_shortcut_state(self.dir, game_id, timeout=1))
with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("CDP_EXCEPTION", "boom")):
with self.assertRaises(agent.AgentError):
agent.steam_shortcut_state(self.dir, game_id, timeout=1)
def test_shortcut_state_command_exits_with_the_state(self):
game_id = str((3333273269 << 32) | 0x02000000)
launch = self.dir.parent
with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(launch)}):
for state, code in (("ready", 0), ("missing", 4), ("not-shortcut", 3), ("loading", 1)):
with self.subTest(state=state), patch.object(agent, "steam_shortcut_state", return_value=state) as ask:
self.assertEqual(code, agent.main(["--steam-shortcut-state", game_id, "2"]))
self.assertEqual((launch / "agent", game_id, "2"), ask.call_args.args)
with patch.object(agent, "steam_shortcut_state", side_effect=agent.AgentError("CDP_EXCEPTION", "boom")):
self.assertEqual(1, agent.main(["--steam-shortcut-state", game_id]))
self.assertEqual(2, agent.main(["--steam-shortcut-state"]))
def test_steam_ready_uses_steams_initialized_store(self):
with patch.object(agent, "cdp_eval", return_value={"value": True}) as evaluate:
self.assertTrue(agent.steam_ready(self.dir))
self.assertIn("m_bIsInitialized", evaluate.call_args.args[1])
with patch.object(agent, "cdp_eval", return_value={"value": False}):
self.assertFalse(agent.steam_ready(self.dir))
def test_pending_interstitial_is_continued_by_steam(self):
with patch.object(agent, "cdp_eval", return_value={"value": True}) as evaluate:
self.assertTrue(agent.steam_clear_pending_interstitial(self.dir, "3551182975"))
expression = evaluate.call_args.args[1]
self.assertIn("ContinueGameAction", expression)
self.assertIn("ShowInterstitials", expression)
self.assertIn('"3551182975"', expression)
self.assertIn("await apps.GetActiveGameActions()", expression)
with patch.object(agent, "cdp_eval", return_value={"value": False}):
self.assertFalse(agent.steam_clear_pending_interstitial(self.dir, "3551182975"))
with self.assertRaises(agent.AgentError):
agent.steam_clear_pending_interstitial(self.dir, "0")
def test_asleep_command_reports_steams_own_sleep_state(self):
with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}):
for value, code in (("yes", 0), ("no", 5), ("unknown", 1), (None, 1)):
with self.subTest(value=value), patch.object(agent, "cdp_eval", return_value={"value": value}) as ask:
self.assertEqual(code, agent.main(["--steam-asleep"]))
self.assertIn("m_bSuspending", ask.call_args.args[1])
self.assertFalse(ask.call_args.kwargs["await_promise"])
with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("NO_DEVTOOLS", "down")):
self.assertEqual(1, agent.main(["--steam-asleep"]))
def test_supports_vr_command_separates_vr_flat_and_unknown_apps(self):
with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}):
for value, code in ((True, 0), (False, 5), (None, 1)):
with self.subTest(value=value), patch.object(agent, "cdp_eval", return_value={"value": value}) as ask:
self.assertEqual(code, agent.main(["--steam-supports-vr", "204360"]))
self.assertIn('GetAppOverviewByGameID?.("204360")', ask.call_args.args[1])
self.assertIn("vr_supported", ask.call_args.args[1])
with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("NO_DEVTOOLS", "down")):
self.assertEqual(1, agent.main(["--steam-supports-vr", "204360"]))
for bad in ([], ["1;x"], ["-1"], ["1", "2"]):
self.assertEqual(2, agent.main(["--steam-supports-vr"] + bad))
def test_idle_inhibit_command_reports_whether_steam_reached_the_state(self):
with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}):
for value, code in ((True, 0), (False, 1), (None, 1)):
with self.subTest(value=value), patch.object(agent, "cdp_eval", return_value={"value": value}) as ask:
self.assertEqual(code, agent.main(["--steam-idle-inhibit", "on"]))
self.assertIn("toggle_idle_suspend_inhibit 1", ask.call_args.args[1])
self.assertTrue(ask.call_args.args[1].rstrip().endswith("(true)"))
with patch.object(agent, "cdp_eval", return_value={"value": True}) as ask:
self.assertEqual(0, agent.main(["--steam-idle-inhibit", "off"]))
self.assertTrue(ask.call_args.args[1].rstrip().endswith("(false)"))
with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("NO_DEVTOOLS", "down")):
self.assertEqual(1, agent.main(["--steam-idle-inhibit", "on"]))
self.assertEqual(2, agent.main(["--steam-idle-inhibit", "maybe"]))
def test_idle_suspend_seconds_follow_the_power_source(self):
vpower = self.dir / "ac_status"
with patch.object(agent, "cdp_eval", return_value={"value": [3600, 900]}):
vpower.write_text("Connected\n")
self.assertEqual(3600, agent.steam_idle_suspend_seconds(self.dir, vpower))
vpower.write_text("Disconnected\n")
self.assertEqual(900, agent.steam_idle_suspend_seconds(self.dir, vpower))
with patch.object(agent, "cdp_eval", return_value={"value": None}):
self.assertIsNone(agent.steam_idle_suspend_seconds(self.dir, vpower))
with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}), \
patch.object(agent, "steam_idle_suspend_seconds", return_value=None):
self.assertEqual(1, agent.main(["--steam-idle-suspend-seconds"]))
def test_cdp_exception(self):
server = FakeDevTools({"result": {"result": {"type": "object"}, "exceptionDetails": {
"text": "Uncaught", "exception": {"description": "ReferenceError: SteamClient is not defined"}}}})
server.start()
self.addCleanup(server.server.close)
(self.dir / "cdp-port").write_text(str(server.port))
answer = self.ask({"kind": "cdp", "expression": "SteamClient"})
self.assertEqual("JS_EXCEPTION", answer["error"]["code"])
self.assertIn("ReferenceError", answer["error"]["message"])
def test_serve_answers_request_files(self):
server = subprocess.Popen([sys.executable, str(SCRIPT), "--no-focus"],
env=dict(os.environ, BL_LAUNCH_DIR=str(self.dir)),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def stop_server():
server.terminate()
server.wait(timeout=5)
self.addCleanup(stop_server)
agent_dir = self.dir / "agent"
for _ in range(100):
if (agent_dir / "hello.json").exists():
break
time.sleep(0.02)
hello = json.loads((agent_dir / "hello.json").read_text())
self.assertEqual(server.pid, hello["pid"])
self.assertFalse(hello["focus"])
(agent_dir / "req" / "a.json.tmp").write_text(json.dumps({"kind": "ping"}))
os.replace(agent_dir / "req" / "a.json.tmp", agent_dir / "req" / "a.json")
for _ in range(200):
if (agent_dir / "resp" / "a.json").exists():
break
time.sleep(0.02)
self.assertEqual(agent.VERSION, json.loads((agent_dir / "resp" / "a.json").read_text())["version"])
def test_game_pids_reads_the_steam_environment(self):
import subprocess
if not Path("/proc").is_dir():
self.skipTest("needs /proc")
child = subprocess.Popen(["sleep", "30"], env=dict(os.environ, SteamAppId="424242"))
self.addCleanup(lambda: (child.kill(), child.wait()))
time.sleep(0.2)
self.assertIn(child.pid, agent.game_pids(424242))
class SteamKeyboardTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.dir = Path(self.temp.name)
def state(self):
try:
return json.loads((self.dir / "keyboard.json").read_text())
except (OSError, ValueError):
return None
def wait_for(self, check):
for _ in range(200):
if check():
return
time.sleep(0.02)
self.fail("timed out; keyboard.json is %s" % self.state())
def test_report_writes_only_changes(self):
watcher = agent.KeyboardWatcher(self.dir)
watcher.report(False)
self.assertEqual({"steamKeyboard": False, "seq": 1}, self.state())
watcher.report(False)
watcher.report(True)
self.assertEqual({"steamKeyboard": True, "seq": 2}, self.state())
def test_follows_the_clients_keyboard_and_rechecks_while_idle(self):
client = FakeSteamClient(False)
client.start()
self.addCleanup(client.server.close)
(self.dir / "cdp-port").write_text(str(client.port))
watcher = agent.KeyboardWatcher(self.dir)
failure = []
with patch.object(agent, "KEYBOARD_CHECK_SECONDS", 0.3):
thread = threading.Thread(target=lambda: failure.append(self.capture(watcher.watch)), daemon=True)
thread.start()
self.wait_for(lambda: self.state() == {"steamKeyboard": False, "seq": 1})
self.assertEqual("Runtime.addBinding", client.seen[0]["method"])
self.assertEqual(agent.KEYBOARD_BINDING, client.seen[0]["params"]["name"])
self.assertIn("IsShowingVirtualKeyboard", client.seen[1]["params"]["expression"])
self.assertIn(agent.KEYBOARD_BINDING, client.seen[1]["params"]["expression"])
self.assertNotIn("__BINDING__", client.seen[1]["params"]["expression"])
client.push("true")
self.wait_for(lambda: (self.state() or {}).get("steamKeyboard") is True)
client.push("false")
self.wait_for(lambda: (self.state() or {}).get("steamKeyboard") is False)
client.showing = True
self.wait_for(lambda: (self.state() or {}).get("steamKeyboard") is True)
self.assertGreaterEqual(len([m for m in client.seen if m["method"] == "Runtime.evaluate"]), 2)
client.drop()
thread.join(5)
self.assertFalse(thread.is_alive())
self.assertIsInstance(failure[0], OSError)
def test_any_loss_reports_the_keyboard_closed_and_backs_off(self):
watcher = agent.KeyboardWatcher(self.dir)
watcher.report(True)
def attached_then_lost():
watcher.attached = True
raise OSError("DevTools closed the connection")
steps = iter([agent.AgentError("NO_DEVTOOLS", "no port"), agent.AgentError("NO_DEVTOOLS", "no port"),
attached_then_lost, KeyError("SteamUIStore")])
def watch():
step = next(steps, None)
if step is None:
raise SystemExit
if callable(step):
step()
raise step
with patch.object(watcher, "watch", side_effect=watch), patch.object(agent.time, "sleep") as sleep, \
patch.object(agent, "log") as log:
with self.assertRaises(SystemExit):
watcher.run()
self.assertEqual({"steamKeyboard": False, "seq": 2}, self.state())
self.assertEqual([2, 4, 2, 4], [c.args[0] for c in sleep.call_args_list])
self.assertFalse(watcher.attached)
self.assertEqual(3, log.call_count)
def test_requests_and_route_files(self):
watcher = agent.KeyboardWatcher(self.dir)
watcher.request()
watcher.request()
self.assertEqual({"seq": 2}, json.loads((self.dir / "keyboard-request.json").read_text()))
self.assertFalse(watcher.wanted_route())
(self.dir / "keyboard-route").write_text("bottom")
self.assertTrue(watcher.wanted_route())
(self.dir / "keyboard-route").write_text("top")
self.assertFalse(watcher.wanted_route())
watcher.event({"method": "Runtime.bindingCalled", "params": {"name": agent.KEYBOARD_BINDING, "payload": "request"}})
self.assertEqual({"seq": 3}, json.loads((self.dir / "keyboard-request.json").read_text()))
self.assertIsNone(self.state())
def test_dismissals_are_written_for_the_app(self):
watcher = agent.KeyboardWatcher(self.dir)
watcher.event({"method": "Runtime.bindingCalled", "params": {"name": agent.KEYBOARD_BINDING, "payload": "dismiss"}})
watcher.event({"method": "Runtime.bindingCalled", "params": {"name": agent.KEYBOARD_BINDING, "payload": "dismiss"}})
self.assertEqual({"seq": 2}, json.loads((self.dir / "keyboard-dismiss.json").read_text()))
self.assertFalse((self.dir / "keyboard-request.json").exists())
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_page_script_reports_when_the_routed_field_loses_focus(self):
harness = r"""
const assert = require('assert');
globalThis.window = globalThis;
const fs = require('fs');
const script = fs.readFileSync(process.argv[2], 'utf8');
const route = fs.readFileSync(process.argv[3], 'utf8');
const calls = [];
window.droiddeckSteamKeyboard = value => calls.push(value);
const open = { m_currentValue: false };
const vkm = {
m_currentVirtualKeyboardRef: null,
IsShowingVirtualKeyboard: { m_currentValue: false },
m_bIsInlineVirtualKeyboardOpen: open,
m_bIsVirtualKeyboardModal: { m_currentValue: false },
BUseVRKeyboard: () => false,
m_Instance: { IsDesktopOverlayWindow: () => false },
SetActiveVirtualKeyboardTarget(ref, props) { this.m_currentVirtualKeyboardRef = ref; },
SetVirtualKeyboardHidden() { open.m_currentValue = false; this.m_currentVirtualKeyboardRef = null; },
ShowVirtualKeyboard(ref, props, modal) { this.m_currentVirtualKeyboardRef = ref; open.m_currentValue = true; },
};
globalThis.SteamUIStore = { WindowStore: { SteamUIWindows: [{ VirtualKeyboardManager: vkm }] } };
eval(script);
vkm.ShowVirtualKeyboard('a', {}, false);
eval(route.replace('__ROUTE__', 'true'));
assert.strictEqual(open.m_currentValue, false);
assert.strictEqual(vkm.m_currentVirtualKeyboardRef, 'a');
const wait = ms => new Promise(done => setTimeout(done, ms));
(async () => {
await wait(500);
assert.deepStrictEqual(calls.filter(c => c === 'dismiss'), []);
vkm.m_currentVirtualKeyboardRef = null;
await wait(500);
assert.deepStrictEqual(calls.filter(c => c === 'dismiss'), ['dismiss']);
vkm.ShowVirtualKeyboard('b', {}, false);
assert.strictEqual(open.m_currentValue, false);
vkm.m_currentVirtualKeyboardRef = null;
await wait(160);
vkm.ShowVirtualKeyboard('c', {}, false);
await wait(500);
assert.deepStrictEqual(calls.filter(c => c === 'dismiss'), ['dismiss']);
vkm.m_currentVirtualKeyboardRef = null;
await wait(500);
assert.deepStrictEqual(calls.filter(c => c === 'dismiss'), ['dismiss', 'dismiss']);
let focused = false;
vkm.ShowVirtualKeyboard({ BIsElementValidForInput: () => focused }, {}, false);
await wait(500);
assert.deepStrictEqual(calls.filter(c => c === 'dismiss'), ['dismiss', 'dismiss']);
focused = true;
await wait(500);
assert.deepStrictEqual(calls.filter(c => c === 'dismiss'), ['dismiss', 'dismiss']);
focused = false;
await wait(500);
assert.deepStrictEqual(calls.filter(c => c === 'dismiss'), ['dismiss', 'dismiss', 'dismiss']);
const doc = { activeElement: { tagName: 'INPUT', type: 'text' } };
vkm.m_ownerWindow = { document: doc };
vkm.ShowVirtualKeyboard({ BIsElementValidForInput: () => false }, {}, false);
await wait(500);
assert.deepStrictEqual(calls.filter(c => c === 'dismiss'), ['dismiss', 'dismiss', 'dismiss']);
doc.activeElement = { tagName: 'DIV' };
await wait(500);
assert.deepStrictEqual(calls.filter(c => c === 'dismiss'), ['dismiss', 'dismiss', 'dismiss', 'dismiss']);
clearInterval(window.__droiddeckKeyboard.timer);
})().catch(e => { console.error(e); process.exit(1); });
"""
(self.dir / "keyboard.js").write_text(agent.KEYBOARD_JS)
(self.dir / "route.js").write_text(agent.KEYBOARD_ROUTE_JS)
(self.dir / "harness.js").write_text(harness)
result = subprocess.run(["node", str(self.dir / "harness.js"), str(self.dir / "keyboard.js"), str(self.dir / "route.js")],
capture_output=True, text=True, timeout=20)
self.assertEqual(0, result.returncode, result.stderr)
def test_route_is_applied_once_per_change(self):
watcher = agent.KeyboardWatcher(self.dir)
sent = []
with patch.object(watcher, "call", side_effect=lambda ws, method, params: sent.append(params["expression"]) or {}):
watcher.apply_route(None)
watcher.apply_route(None)
(self.dir / "keyboard-route").write_text("bottom")
watcher.apply_route(None)
watcher.apply_route(None, force=True)
self.assertEqual(3, len(sent))
self.assertTrue(sent[0].rstrip().endswith("(false)"))
self.assertTrue(sent[1].rstrip().endswith("(true)"))
self.assertNotIn("__ROUTE__", sent[2])
self.assertNotIn("__BINDING__", sent[2])
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_page_script_routes_the_keyboard_to_the_app(self):
harness = r"""
const assert = require('assert');
globalThis.window = globalThis;
const fs = require('fs');
const script = fs.readFileSync(process.argv[2], 'utf8');
const route = fs.readFileSync(process.argv[3], 'utf8');
const calls = [];
window.droiddeckSteamKeyboard = value => calls.push(value);
const shown = [], targets = [];
const open = { m_currentValue: false };
const vkm = {
IsShowingVirtualKeyboard: { m_currentValue: false },
m_bIsInlineVirtualKeyboardOpen: open,
m_bIsVirtualKeyboardModal: { m_currentValue: false },
BUseVRKeyboard: () => false,
m_Instance: { IsDesktopOverlayWindow: () => false },
SetActiveVirtualKeyboardTarget(ref, props) { targets.push(ref); },
SetVirtualKeyboardHidden() { open.m_currentValue = false; },
ShowVirtualKeyboard(ref, props, modal) { shown.push(ref); open.m_currentValue = true; },
};
globalThis.SteamUIStore = { WindowStore: { SteamUIWindows: [{ VirtualKeyboardManager: vkm }] } };
eval(script);
vkm.ShowVirtualKeyboard('a', {}, false);
assert.deepStrictEqual(shown, ['a']);
assert.strictEqual(eval(route.replace('__ROUTE__', 'true')), true);
assert.strictEqual(open.m_currentValue, false);
assert.deepStrictEqual(calls.filter(c => c === 'request'), ['request']);
vkm.ShowVirtualKeyboard('b', {}, false);
assert.deepStrictEqual(shown, ['a']);
assert.deepStrictEqual(targets, ['b']);
assert.deepStrictEqual(calls.filter(c => c === 'request'), ['request', 'request']);
vkm.ShowVirtualKeyboard('c', {}, true);
assert.deepStrictEqual(shown, ['a', 'c']);
eval(script);
vkm.ShowVirtualKeyboard('d', {}, false);
assert.deepStrictEqual(targets, ['b', 'd']);
eval(route.replace('__ROUTE__', 'false'));
vkm.ShowVirtualKeyboard('e', {}, false);
assert.deepStrictEqual(shown, ['a', 'c', 'e']);
clearInterval(window.__droiddeckKeyboard.timer);
"""
(self.dir / "keyboard.js").write_text(agent.KEYBOARD_JS)
(self.dir / "route.js").write_text(agent.KEYBOARD_ROUTE_JS)
(self.dir / "harness.js").write_text(harness)
result = subprocess.run(["node", str(self.dir / "harness.js"), str(self.dir / "keyboard.js"), str(self.dir / "route.js")],
capture_output=True, text=True, timeout=20)
self.assertEqual(0, result.returncode, result.stderr)
def test_no_shared_context_yet(self):
with patch.object(agent, "cdp_targets", return_value=[{"title": "Steam", "webSocketDebuggerUrl": "ws://127.0.0.1:1/x"}]):
with self.assertRaises(agent.AgentError) as caught:
agent.KeyboardWatcher(self.dir).watch()
self.assertEqual("NO_TARGET", caught.exception.code)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_page_script_watches_every_window_with_or_without_subscribe(self):
harness = r"""
const assert = require('assert');
globalThis.window = globalThis;
const script = require('fs').readFileSync(process.argv[2], 'utf8');
const calls = [];
window.droiddeckSteamKeyboard = value => calls.push(value);
const tick = ms => new Promise(done => setTimeout(done, ms));
const value = subscribe => {
const v = { m_currentValue: false, m_callbacks: [] };
if (subscribe) v.Subscribe = cb => { v.m_callbacks.push(cb); return { Unsubscribe() {} }; };
v.set = next => { v.m_currentValue = next; for (const cb of v.m_callbacks) cb(next); };
return v;
};
(async () => {
assert.strictEqual(eval(script), false);
assert.deepStrictEqual(calls, ['false']);
const main = value(true), overlay = value(false);
globalThis.SteamUIStore = { WindowStore: { SteamUIWindows: [{ VirtualKeyboardManager: { IsShowingVirtualKeyboard: main } }] } };
assert.strictEqual(eval(script), false);
main.set(true);
await tick(20);
assert.deepStrictEqual(calls, ['false', 'true']);
main.set(false);
await tick(20);
SteamUIStore.WindowStore.SteamUIWindows.push({}, { VirtualKeyboardManager: { IsShowingVirtualKeyboard: overlay } });
overlay.m_currentValue = true;
await tick(700);
assert.deepStrictEqual(calls, ['false', 'true', 'false', 'true']);
assert.strictEqual(eval(script), true);
assert.strictEqual(main.m_callbacks.length, 1);
clearInterval(window.__droiddeckKeyboard.timer);
})().catch(e => { console.error(e); process.exit(1); });
"""
(self.dir / "keyboard.js").write_text(agent.KEYBOARD_JS)
(self.dir / "harness.js").write_text(harness)
result = subprocess.run(["node", str(self.dir / "harness.js"), str(self.dir / "keyboard.js")],
capture_output=True, text=True, timeout=20)
self.assertEqual(0, result.returncode, result.stderr)
@staticmethod
def capture(call):
try:
call()
except Exception as e:
return e
class VrFocusTest(unittest.TestCase):
def test_scene_dashboard_and_external_focus_changes(self):
focus = agent.VrSceneFocus()
self.assertEqual(focus.order([9, 7], [7, 42], 42), [42, 9, 7])
self.assertIsNone(focus.order([42, 9, 7], [7, 42], 42))
self.assertEqual(focus.order([42, 9, 7], [7, 42], None), [9, 7])
self.assertIsNone(focus.order([9, 7], [7], 42))
self.assertEqual(focus.order([9, 7], [7, 42], 42), [42, 9, 7])
self.assertIsNone(focus.order([123, 7], [7, 42], None))
def test_scene_requires_live_server_and_game_environment(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
state = root / 'scene.json'
for pid in (100, 200):
(root / str(pid)).mkdir()
(root / '100/comm').write_text('vrserver\n')
(root / '200/environ').write_bytes(b'SteamAppId=42\0Other=value\0')
state.write_text(json.dumps(dict(serverPid=100, scenePid=200, dashboard=False)))
self.assertEqual(agent.vr_scene_app(state, root), 42)
state.write_text(json.dumps(dict(serverPid=100, scenePid=200, dashboard=True)))
self.assertIsNone(agent.vr_scene_app(state, root))
state.write_text(json.dumps(dict(serverPid=100, scenePid=200, dashboard=False)))
(root / '100/comm').write_text('other\n')
self.assertIsNone(agent.vr_scene_app(state, root))
(root / '100/comm').unlink()
self.assertIsNone(agent.vr_scene_app(state, root))
if __name__ == "__main__":
unittest.main()