Files
DroidDeck/tools/tests/test_droiddeck_agent.py
MaxsTechReview c5577478ca chore: remove source comments and unused code, translate every missing string (#1965)
* chore(i18n): translate every missing string in all locales

Adds the 87 strings the bottom-screen Dashboard and in-game stats pages
introduced to all 32 locales, plus the 51 older settings strings Japanese
and the 50 Chinese locales were still missing. Every locale now has all
3456 translatable strings with the same placeholders as English.

Also translates 36 entries that were still English where the language
does not use the English word, and makes es-419 touch_app_id use the same
%1$s placeholder as English.

* chore(patches): drop comments from the lines downstream patches add

Removes comment lines and trailing comments from lines our patches add,
with hunk headers recounted. Lines that a later patch uses as context
and the upstream backport 0123 are left as they are.

The gamescope, FEX, proot and xodus series still apply to their pinned
sources, and the patched trees differ only in those comments.
test_hud_frames now finds the HUD writer by its first declaration.

* docs: credit Greenlight, Better xCloud, viture-v2, GameNative and DiRTTelemetryErrorFix

These were credited only in source headers and NOTICE files.

* chore: remove source comments and unused functions

Removes explanatory comments from first-party Kotlin, Java, C, C++,
Rust, TypeScript, Python, shell, Gradle, CMake and workflow files.
License and attribution headers stay, and vendored third-party trees,
generated files and license files are untouched. A shellcheck directive
is kept, and udevmon's fallthrough comments became fallthrough attributes.

Syntax trees and token streams match the previous sources apart from the
comments and Python docstrings. Module docstrings that argparse prints
are kept, and device-smoke.sh prints the same --help text from a heredoc.

Also removes 37 functions and 4 fields that nothing referenced.

* chore: remove comments from the Syncthing integration and the latest main sources

* docs: credit Syncthing and the syncthing-android fork

* chore(i18n): translate the Syncthing strings in all locales

The 107 strings the Syncthing integration added are now in all 32 locales.
sync_web_path_locked names the Syncthing tab by its label.

* docs: credit @xXJSONDeruloXx for the HUD metric readers

The CPU, GPU load, thermal and battery readers come from their
PerformanceHudView pull requests #777, #877, #991, #1406 and #1573.
The FusionHUD credit line is unchanged.

* docs: credit the HUD metric readers to @xXJSONDeruloXx's work
2026-11-01 16:27:27 -05:00

881 lines
45 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_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()