mirror of
https://github.com/ClassicWasTaken/HaloCE-SteamFrame.git
synced 2026-10-23 06:00:26 +02:00
662 lines
34 KiB
Python
662 lines
34 KiB
Python
"""Exercise the exact CDP expressions against a client API, without a headset."""
|
|
import importlib.util
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
|
|
RESOURCE = Path(__file__).resolve().parents[1] / "resources/steam_live.py"
|
|
|
|
|
|
@pytest.fixture
|
|
def live(monkeypatch):
|
|
spec = importlib.util.spec_from_file_location("steam_live_test", RESOURCE)
|
|
module = importlib.util.module_from_spec(spec)
|
|
monkeypatch.setitem(sys.modules, spec.name, module)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
NODE_CLIENT = r"""
|
|
const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
|
const calls = [], store = new Map(), data = new Map();
|
|
const id = 3000000001;
|
|
for (const entry of input.entries || []) {
|
|
store.set(entry.appid, {appid:entry.appid, app_type:1073741824, display_name:entry.name,
|
|
icon_data:input.iconData === undefined ? (input.iconUnloaded ? undefined : '') : input.iconData});
|
|
data.set(entry.appid, {unAppID:entry.appid, strShortcutExe:entry.exe,
|
|
strShortcutStartDir:entry.directory || input.spec.directory,
|
|
strLaunchOptions:entry.options || '', strShortcutLaunchOptions:entry.options || '',
|
|
bShortcutIsVR:false, strCompatToolName:''});
|
|
}
|
|
let active = false;
|
|
async function setter(method, appid, value, field) {
|
|
if (active) throw new Error('Concurrent client setters');
|
|
active = true; calls.push([method, appid, value]);
|
|
await new Promise(resolve => setTimeout(resolve, 1));
|
|
if (field) data.get(appid)[field] = value;
|
|
if (method === 'SetShortcutName') store.get(appid).display_name = value;
|
|
active = false;
|
|
}
|
|
globalThis.appStore = {m_mapApps:store,GetAppOverviewByAppID:appid => store.get(appid)};
|
|
globalThis.SteamClient = {Apps:{
|
|
AddShortcut:async(name, exe, arguments_, cmdline) => {
|
|
calls.push(['AddShortcut',name,exe,arguments_,cmdline]);
|
|
const result = input.returnId || id;
|
|
store.set(result,{appid:result,app_type:1073741824,display_name:'halo',icon_data:''});
|
|
data.set(result,{unAppID:result,strShortcutExe:exe,strShortcutStartDir:input.spec.directory+'/',
|
|
strLaunchOptions:arguments_,strShortcutLaunchOptions:arguments_,bShortcutIsVR:false,strCompatToolName:''});
|
|
return result;
|
|
},
|
|
RemoveShortcut:async appid => {calls.push(['RemoveShortcut',appid]);store.delete(appid);data.delete(appid);},
|
|
RegisterForAppDetails:(appid, callback) => {
|
|
calls.push(['RegisterForAppDetails',appid]);
|
|
queueMicrotask(() => callback({...data.get(appid)}));
|
|
return {unregister:() => calls.push(['unregister',appid])};
|
|
},
|
|
SetShortcutName:(appid,value) => setter('SetShortcutName',appid,value),
|
|
SetShortcutExe:(appid,value) => setter('SetShortcutExe',appid,value,'strShortcutExe'),
|
|
SetShortcutStartDir:(appid,value) => setter('SetShortcutStartDir',appid,value,'strShortcutStartDir'),
|
|
SetShortcutLaunchOptions:(appid,value) => setter('SetShortcutLaunchOptions',appid,value,'strShortcutLaunchOptions'),
|
|
SetAppLaunchOptions:(appid,value) => setter('SetAppLaunchOptions',appid,value,'strLaunchOptions'),
|
|
SpecifyCompatTool:(appid,value) => setter('SpecifyCompatTool',appid,value,'strCompatToolName'),
|
|
SetShortcutIsVR:(appid,value) => setter('SetShortcutIsVR',appid,value,'bShortcutIsVR'),
|
|
SetShortcutIcon:(appid,value) => setter('SetShortcutIcon',appid,value),
|
|
RequestIconDataForApp:appid => {
|
|
calls.push(['RequestIconDataForApp',appid]);
|
|
if (input.loadedIconData !== undefined) store.get(appid).icon_data = input.loadedIconData;
|
|
},
|
|
SetCustomArtworkForApp:async(appid,base64Data,extension,assetType) => {
|
|
calls.push(['SetCustomArtworkForApp',appid,base64Data,extension,assetType]);
|
|
await new Promise(resolve => setTimeout(resolve,1));
|
|
},
|
|
}};
|
|
globalThis.loginStore = {currentUser:{accountName:'active-user'}};
|
|
for (const key of input.missing || []) delete SteamClient.Apps[key];
|
|
(async() => {
|
|
try {
|
|
const probe = await eval(input.probe);
|
|
const secondProbe = await eval(input.probe);
|
|
const result = probe.supported ? await eval(input.expression) : probe;
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
process.stdout.write(JSON.stringify({probe,secondProbe,result,calls,entries:[...store.values()],data:[...data.values()]}));
|
|
} catch(error) {
|
|
process.stdout.write(JSON.stringify({error:String(error),calls,entries:[...store.values()]}));
|
|
}
|
|
})();
|
|
"""
|
|
|
|
|
|
def run_client(live, operation="add", source=None, spec_values=None, **options):
|
|
node = shutil.which("node")
|
|
if not node:
|
|
pytest.skip("Node is required to exercise the actual Steam API expression")
|
|
spec = live.native_spec(live.HOME, live.GAME, live.NAME)
|
|
spec["operation"] = operation
|
|
spec.update(spec_values or {})
|
|
body = {"spec": spec, "probe": live.expression(live.PROBE, spec),
|
|
"expression": live.expression(source or live.LIBRARY, spec), **options}
|
|
result = subprocess.run([node, "-e", NODE_CLIENT], input=json.dumps(body),
|
|
text=True, capture_output=True, timeout=12, check=True)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def entry(live, appid=3000000001, **values):
|
|
return {"appid": appid, "name": live.NAME, "exe": str(live.GAME / "halo"), **values}
|
|
|
|
|
|
def test_add_awaits_every_setter_and_verifies_settings_in_fresh_eval_scope(live):
|
|
result = run_client(live)
|
|
assert result["probe"] == result["secondProbe"] == {"supported": True, "missing": []}
|
|
assert result["result"]["status"] == "added"
|
|
verified = result["result"]["verified"]
|
|
assert verified["launchOptions"] == verified["shortcutLaunchOptions"] == live.LAUNCH_OPTIONS
|
|
assert verified["vr"] is True and verified["compatibilityTool"] == ""
|
|
methods = [call[0] for call in result["calls"] if call[0].startswith("Set") or call[0] == "SpecifyCompatTool"]
|
|
assert methods == ["SetShortcutName", "SetShortcutExe", "SetShortcutStartDir",
|
|
"SetShortcutLaunchOptions", "SetAppLaunchOptions", "SpecifyCompatTool", "SetShortcutIsVR"]
|
|
assert result["result"]["created"] is True
|
|
assert sum(call[0] == "unregister" for call in result["calls"]) == 3
|
|
add = next(call for call in result["calls"] if call[0] == "AddShortcut")
|
|
assert add == ["AddShortcut", live.NAME, str(live.GAME / "halo"), "", '"' + str(live.GAME / "halo") + '"']
|
|
|
|
|
|
def test_existing_native_is_repaired_without_creating_duplicate(live):
|
|
result = run_client(live, entries=[entry(live), entry(live, appid=3000000002, name="Other game", exe="/other")])
|
|
assert result["result"]["created"] is False
|
|
assert not any(call[0] == "AddShortcut" for call in result["calls"])
|
|
assert len(result["entries"]) == 2
|
|
assert result["data"][1]["strShortcutExe"] == "/other"
|
|
|
|
|
|
@pytest.mark.parametrize("entries_kind, expected", [("foreign", "different game"), ("duplicate", "Multiple exact"), ("collision", "different game")])
|
|
def test_ambiguous_and_foreign_identity_refuses_all_mutations(live, entries_kind, expected):
|
|
entries = {
|
|
"foreign": [entry(live, exe="/other/game")],
|
|
"duplicate": [entry(live), entry(live, appid=3000000002)],
|
|
"collision": [entry(live, appid=live.native_spec(live.HOME, live.GAME, live.NAME)["candidateAppids"][0], name="Other", exe="/other")],
|
|
}[entries_kind]
|
|
result = run_client(live, entries=entries)
|
|
assert expected in result["error"]
|
|
assert all(call[0] in ("RegisterForAppDetails", "unregister") for call in result["calls"])
|
|
|
|
|
|
def test_known_crc_native_entry_is_found_even_when_user_renamed_it(live):
|
|
appid = live.native_spec(live.HOME, live.GAME, live.NAME)["candidateAppids"][0]
|
|
result = run_client(live, entries=[entry(live, appid=appid, name="My Halo")])
|
|
assert result["result"]["appid"] == appid and result["result"]["created"] is False
|
|
assert result["entries"][0]["display_name"] == live.NAME
|
|
|
|
|
|
def test_any_renamed_native_entry_is_found_by_exact_executable(live):
|
|
result = run_client(live, entries=[entry(live, name="halo")])
|
|
assert result["result"]["appid"] == 3000000001 and result["result"]["created"] is False
|
|
assert not any(call[0] == "AddShortcut" for call in result["calls"])
|
|
|
|
|
|
def test_remove_renamed_native_entry_uses_exact_path(live):
|
|
result = run_client(live, "remove", entries=[entry(live, name="Custom Halo name")])
|
|
assert result["result"]["status"] == "removed" and result["entries"] == []
|
|
|
|
|
|
def test_remove_exact_identity_and_leave_foreign_same_name_entry(live):
|
|
result = run_client(live, "remove", entries=[entry(live), entry(live, appid=3000000002, exe="/foreign")])
|
|
assert result["result"] == {"ok": True, "status": "removed", "appid": 3000000001}
|
|
assert result["entries"] == [{"appid": 3000000002, "app_type": 1073741824, "display_name": live.NAME, "icon_data": ""}]
|
|
assert [call for call in result["calls"] if call[0] == "RemoveShortcut"] == [["RemoveShortcut", 3000000001]]
|
|
|
|
|
|
def test_remove_absent_is_idempotent_and_does_not_mutate(live):
|
|
result = run_client(live, "remove", entries=[entry(live, exe="/foreign")])
|
|
assert result["result"] == {"ok": True, "status": "already-absent"}
|
|
assert all(call[0] in ("RegisterForAppDetails", "unregister") for call in result["calls"])
|
|
|
|
|
|
def test_capability_probe_is_read_only(live):
|
|
result = run_client(live, missing=["SpecifyCompatTool"])
|
|
assert result["result"] == {"supported": False, "missing": ["SpecifyCompatTool"]}
|
|
assert result["calls"] == [] and result["entries"] == []
|
|
|
|
|
|
def test_python_readback_rejects_missing_compatibility_field(live, monkeypatch):
|
|
spec = live.native_spec(live.HOME, live.GAME, live.NAME)
|
|
value = {"ok": True, "status": "added", "appid": 3000000001, "verified": {
|
|
"appid": 3000000001, "exe": spec["exe"], "directory": spec["directory"],
|
|
"launchOptions": live.LAUNCH_OPTIONS, "shortcutLaunchOptions": live.LAUNCH_OPTIONS, "vr": True}}
|
|
client = Mock()
|
|
client.__enter__ = Mock(return_value=client)
|
|
client.__exit__ = Mock(return_value=False)
|
|
client.evaluate.side_effect = [{"supported": True}, value]
|
|
monkeypatch.setattr(live, "Client", lambda: client)
|
|
with pytest.raises(live.LiveSteamError, match="readback"):
|
|
live.add_native(live.HOME, live.GAME)
|
|
|
|
|
|
def test_missing_live_capability_never_sends_the_mutating_expression(live, monkeypatch):
|
|
client = Mock()
|
|
client.__enter__ = Mock(return_value=client)
|
|
client.__exit__ = Mock(return_value=False)
|
|
client.evaluate.return_value = {"supported": False, "missing": ["AddShortcut"]}
|
|
monkeypatch.setattr(live, "Client", lambda: client)
|
|
with pytest.raises(live.LiveSteamError, match="unavailable"):
|
|
live.add_native(live.HOME, live.GAME)
|
|
assert client.evaluate.call_count == 1
|
|
assert "supported" in client.evaluate.call_args.args[0]
|
|
|
|
|
|
@pytest.mark.parametrize("targets", [[], [{"title": "SharedJSContext"}],
|
|
[{"title": "SharedJSContext", "webSocketDebuggerUrl": "ws://127.0.0.1:8080/devtools/page/1"}] * 2])
|
|
def test_target_selection_requires_exactly_one_verified_context(live, monkeypatch, targets):
|
|
monkeypatch.setattr(live, "verify_endpoint_owner", lambda: None)
|
|
response = Mock()
|
|
response.__enter__ = Mock(return_value=response)
|
|
response.__exit__ = Mock()
|
|
response.read.return_value = json.dumps(targets).encode()
|
|
opener = Mock()
|
|
opener.open.return_value = response
|
|
builder = Mock(return_value=opener)
|
|
monkeypatch.setattr(live.urllib.request, "build_opener", builder)
|
|
connect = Mock(side_effect=AssertionError("Unexpected WebSocket connection"))
|
|
monkeypatch.setattr(live, "WebSocket", connect)
|
|
with pytest.raises(live.LiveSteamError, match="unavailable or ambiguous"):
|
|
live.Client()
|
|
connect.assert_not_called()
|
|
assert builder.call_args.args[0].proxies == {}
|
|
redirect = builder.call_args.args[1]
|
|
assert redirect.redirect_request(None, None, 302, "redirect", {}, "https://example.org") is None
|
|
|
|
|
|
def test_target_list_size_is_checked_before_json_decoding(live, monkeypatch):
|
|
monkeypatch.setattr(live, "verify_endpoint_owner", lambda: None)
|
|
response = Mock()
|
|
response.__enter__ = Mock(return_value=response)
|
|
response.__exit__ = Mock()
|
|
response.read.return_value = b"x" * (live.MAX_BYTES + 1)
|
|
opener = Mock()
|
|
opener.open.return_value = response
|
|
monkeypatch.setattr(live.urllib.request, "build_opener", lambda *args: opener)
|
|
with pytest.raises(live.LiveSteamError, match="bounds"):
|
|
live.Client()
|
|
response.read.assert_called_once_with(live.MAX_BYTES + 1)
|
|
|
|
|
|
@pytest.mark.parametrize("owners, accepted", [([1000], True), ([1000, 1000], True),
|
|
([1001], False), ([1000, 1001], False), ([], False)])
|
|
def test_loopback_endpoint_requires_current_uid_owned_listeners(live, tmp_path, monkeypatch, owners, accepted):
|
|
monkeypatch.setattr(live.os, "getuid", lambda: 1000, raising=False)
|
|
header = "sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"
|
|
rows = [f"{index}: 0100007F:1F90 00000000:0000 0A 0:0 00:00000000 0 {uid} 0 123\n"
|
|
for index, uid in enumerate(owners)]
|
|
(tmp_path / "tcp").write_text(header + "".join(rows))
|
|
if accepted:
|
|
live.verify_endpoint_owner(tmp_path)
|
|
else:
|
|
with pytest.raises(live.LiveSteamError, match="does not belong"):
|
|
live.verify_endpoint_owner(tmp_path)
|
|
|
|
|
|
def test_foreign_endpoint_never_contacts_http(live, monkeypatch):
|
|
monkeypatch.setattr(live, "verify_endpoint_owner", Mock(side_effect=live.LiveSteamError("foreign account")))
|
|
opener = Mock(side_effect=AssertionError("Foreign Steam must not receive a request"))
|
|
monkeypatch.setattr(live.urllib.request, "build_opener", opener)
|
|
with pytest.raises(live.LiveSteamError, match="foreign account"):
|
|
live.Client()
|
|
opener.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("url", ["ws://example.org:8080/devtools/page/1", "wss://127.0.0.1:8080/devtools/page/1",
|
|
"ws://127.0.0.1:80/devtools/page/1", "ws://user@127.0.0.1:8080/devtools/page/1",
|
|
"ws://127.0.0.1:8080/other", "ws://127.0.0.1:8080/devtools/page/1\n"])
|
|
def test_transport_refuses_nonverified_endpoints_before_connecting(live, monkeypatch, url):
|
|
connect = Mock(side_effect=AssertionError("Unexpected network connection"))
|
|
monkeypatch.setattr(live.socket, "create_connection", connect)
|
|
with pytest.raises(live.LiveSteamError):
|
|
live.WebSocket(url, time.monotonic() + 30)
|
|
connect.assert_not_called()
|
|
|
|
|
|
def test_websocket_fragmented_text_and_ping_are_bounded(live):
|
|
ws = live.WebSocket.__new__(live.WebSocket)
|
|
ws.deadline = time.monotonic() + 30
|
|
ws.sock = Mock()
|
|
ws.pending = bytearray(b'\x01\x02{"\x89\x01P\x80\x05x":1}')
|
|
sent = []
|
|
ws.send = lambda payload, opcode=1: sent.append((payload, opcode))
|
|
assert ws.receive_text() == '{"x":1}'
|
|
assert sent == [(b"P", 10)]
|
|
ws.pending = bytearray(b"\x81\x7f" + struct.pack("!Q", live.MAX_BYTES + 1))
|
|
with pytest.raises(live.LiveSteamError, match="bounds"):
|
|
ws.receive_text()
|
|
|
|
|
|
def test_native_spec_rejects_unmanaged_paths(live):
|
|
with pytest.raises(live.LiveSteamError, match="restricted"):
|
|
live.native_spec(live.HOME, live.GAME / "../foreign", live.NAME)
|
|
|
|
|
|
@pytest.fixture
|
|
def artwork_client(live, tmp_path, monkeypatch):
|
|
spec = importlib.util.spec_from_file_location("steam_shortcut", RESOURCE.parent / "steam_shortcut.py")
|
|
shortcut = importlib.util.module_from_spec(spec)
|
|
monkeypatch.setitem(sys.modules, "steam_shortcut", shortcut)
|
|
spec.loader.exec_module(shortcut)
|
|
home = tmp_path / "home"
|
|
monkeypatch.setattr(live, "HOME", home)
|
|
monkeypatch.setattr(live, "GAME", home / "Games/HaloCENativeVR")
|
|
steam = home / ".local/share/Steam"
|
|
config = steam / "config"
|
|
config.mkdir(parents=True)
|
|
grid = steam / "userdata/10/config/grid"
|
|
grid.mkdir(parents=True)
|
|
foreign = steam / "userdata/20/config/grid"
|
|
foreign.mkdir(parents=True)
|
|
(foreign / "unrelated.png").write_bytes(b"another account's custom art")
|
|
login = config / "loginusers.vdf"
|
|
login.write_text('"users" { "76561197960265738" { "AccountName" "active-user" "MostRecent" "0" } '
|
|
'"76561197960265748" { "AccountName" "another-user" "MostRecent" "1" } }')
|
|
game = live.GAME
|
|
game.mkdir(parents=True)
|
|
program = bytearray(64)
|
|
program[:6] = b"\x7fELF\x02\x01"
|
|
program[18:20] = (183).to_bytes(2, "little")
|
|
(game / "halo").write_bytes(program)
|
|
(game / ".halo-frame-installer.json").write_text(json.dumps({
|
|
"owner": "halo-frame-installer", "sourceCommit": shortcut.ICON_SOURCE_COMMIT,
|
|
"files": {"halo": hashlib.sha256(program).hexdigest()}}))
|
|
record, _ = shortcut.update_shortcut(None, str(game / "halo"), str(game))
|
|
root = shortcut.loads(record)
|
|
root["shortcuts"].value["0"].value["appid"].value = 3000000001
|
|
persisted = grid.parent / "shortcuts.vdf"
|
|
persisted.write_bytes(shortcut.dumps(root))
|
|
|
|
class ArtworkClient:
|
|
deadline = time.monotonic() + 30
|
|
account = "active-user"
|
|
writes = []
|
|
icon_writes = []
|
|
calls = 0
|
|
fail_write = False
|
|
switch_before_write = False
|
|
corrupt_write = False
|
|
switch_after_write = False
|
|
icon_state = "empty"
|
|
load_icon = False
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
return False
|
|
|
|
def evaluate(self, expression):
|
|
self.calls += 1
|
|
encoded = expression.removeprefix("(() => {const S = ").split(";\nreturn ", 1)[0]
|
|
request = json.loads(encoded)
|
|
if "iconPath" in request:
|
|
self.icon_writes.append(request)
|
|
if self.fail_write:
|
|
raise live.LiveSteamError("Steam shortcut icon API did not finish")
|
|
record = shortcut.loads(persisted.read_bytes())
|
|
record["shortcuts"].value["0"].value["icon"].value = "/custom/concurrent.png" if self.corrupt_write else request["iconPath"]
|
|
persisted.write_bytes(shortcut.dumps(record)) # Simulate Steam's own path setter.
|
|
if self.switch_after_write:
|
|
self.account = "another-user"
|
|
return {"ok": True, "accountName": self.account, "appid": request["appid"]}
|
|
if "base64Data" in request:
|
|
self.writes.append(request)
|
|
if self.fail_write:
|
|
raise live.LiveSteamError("Steam artwork API did not finish")
|
|
payload = b"unexpected client image" if self.corrupt_write else base64.b64decode(request["base64Data"])
|
|
suffix = {0: "p", 1: "_hero", 2: "_logo", 3: ""}[request["assetType"]]
|
|
target = grid / (str(request["appid"]) + suffix + "." + request["extension"])
|
|
target.write_bytes(payload) # Simulate only Steam's own API filesystem write.
|
|
if self.switch_after_write:
|
|
self.account = "another-user"
|
|
return {"ok": True, "accountName": self.account, "appid": request["appid"]}
|
|
if self.switch_before_write and self.calls >= 2:
|
|
self.account = "another-user"
|
|
state = self.icon_state
|
|
requested = state == "unknown" and request.get("requestIcon") is True and self.load_icon
|
|
if requested:
|
|
self.icon_state = "empty"
|
|
return {"accountName": self.account, "appid": request["appid"], "exe": request["exe"],
|
|
"iconState": state, "iconRequested": requested}
|
|
|
|
client = ArtworkClient()
|
|
monkeypatch.setattr(live, "Client", lambda: client)
|
|
return home, live.GAME, grid, foreign, login, client, shortcut
|
|
|
|
|
|
def test_live_artwork_uses_api_types_and_active_account_not_most_recent(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
result = live._live_artwork(home, game, 3000000001)
|
|
assert result["status"] == "added"
|
|
assert result["installed"] == ["3000000001" + suffix + extension for suffix, extension, _, _ in shortcut.ARTWORK]
|
|
assert [(item["extension"], item["assetType"]) for item in client.writes] == [
|
|
(extension.lstrip("."), shortcut.ARTWORK_TYPES[suffix]) for suffix, extension, _, _ in shortcut.ARTWORK]
|
|
for suffix, extension, filename, expected in shortcut.ARTWORK:
|
|
assert hashlib.sha256((grid / ("3000000001" + suffix + extension)).read_bytes()).hexdigest() == expected
|
|
assert list(foreign.iterdir()) == [foreign / "unrelated.png"]
|
|
assert login.read_text().count('"MostRecent" "1"') == 1
|
|
|
|
|
|
def test_live_artwork_preserves_custom_extensions_and_reuses_managed_hash(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
(grid / "3000000001p.webp").write_bytes(b"custom portrait")
|
|
(grid / "3000000001.png").write_bytes((RESOURCE.parent / "artwork/halo-ce-landscape.png").read_bytes())
|
|
for suffix, extension, filename, _ in shortcut.ARTWORK:
|
|
if suffix not in ("p", ""):
|
|
(grid / ("3000000001" + suffix + extension)).write_bytes((RESOURCE.parent / "artwork" / filename).read_bytes())
|
|
before = {path.name: path.read_bytes() for path in grid.iterdir()}
|
|
result = live._live_artwork(home, game, 3000000001)
|
|
unchanged = ["3000000001" + suffix + extension for suffix, extension, _, _ in shortcut.ARTWORK if suffix != "p"]
|
|
assert result == {"status": "preserved", "installed": [], "preserved": ["3000000001p.webp"], "unchanged": unchanged}
|
|
assert not client.writes
|
|
assert {path.name: path.read_bytes() for path in grid.iterdir()} == before
|
|
|
|
|
|
@pytest.mark.parametrize("profile", ["missing", "ambiguous", "duplicate-field"])
|
|
def test_artwork_unmatched_or_ambiguous_account_never_writes(live, artwork_client, profile):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
replacements = {
|
|
"missing": '"users" {"76561197960265738" {"AccountName" "different-user"}}',
|
|
"ambiguous": '"users" {"76561197960265738" {"AccountName" "active-user"} '
|
|
'"76561197960265748" {"AccountName" "active-user"}}',
|
|
"duplicate-field": '"users" {"76561197960265738" {"AccountName" "different" "AccountName" "active-user"}}',
|
|
}
|
|
login.write_text(replacements[profile])
|
|
with pytest.raises((live.LiveSteamError, ValueError)):
|
|
live._live_artwork(home, game, 3000000001)
|
|
assert not client.writes and not list(grid.iterdir())
|
|
|
|
|
|
def test_live_artwork_account_change_before_write_preserves_both_accounts(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
client.switch_before_write = True
|
|
with pytest.raises(live.LiveSteamError, match="changed"):
|
|
live._live_artwork(home, game, 3000000001)
|
|
assert not client.writes and not list(grid.iterdir())
|
|
assert (foreign / "unrelated.png").read_bytes() == b"another account's custom art"
|
|
|
|
|
|
def test_live_artwork_source_integrity_is_checked_before_any_client_request(live, artwork_client, tmp_path):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
sources = tmp_path / "corrupt-art"
|
|
sources.mkdir()
|
|
for _, _, name, _ in shortcut.ARTWORK:
|
|
(sources / name).write_bytes(b"bad source")
|
|
with pytest.raises(live.LiveSteamError, match="integrity"):
|
|
live._live_artwork(home, game, 3000000001, sources)
|
|
assert client.calls == 0 and not client.writes and not list(grid.iterdir())
|
|
|
|
|
|
def test_artwork_failure_keeps_verified_library_result_and_reports_manual(live, artwork_client, monkeypatch):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
monkeypatch.setattr(live, "_library", lambda *args: {"appid": 3000000001, "created": False})
|
|
client.fail_write = True
|
|
result = live.add_native(home, game)
|
|
assert result["status"] == "added" and result["libraryAdded"] is True
|
|
assert result["artwork"]["status"] == "manual"
|
|
assert "Steam artwork API" in result["artwork"]["reason"]
|
|
assert not list(grid.iterdir())
|
|
|
|
|
|
def test_wrong_artwork_readback_is_not_reported_complete_or_deleted(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
client.corrupt_write = True
|
|
with pytest.raises(live.LiveSteamError, match="integrity"):
|
|
live._live_artwork(home, game, 3000000001)
|
|
assert (grid / "3000000001p.jpg").read_bytes() == b"unexpected client image"
|
|
assert not (grid / "3000000001.png").exists()
|
|
|
|
|
|
def test_artwork_account_change_after_api_is_not_reported_complete(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
client.switch_after_write = True
|
|
with pytest.raises(live.LiveSteamError, match="could not be verified"):
|
|
live._live_artwork(home, game, 3000000001)
|
|
assert len(client.writes) == 1
|
|
assert (foreign / "unrelated.png").read_bytes() == b"another account's custom art"
|
|
|
|
|
|
def test_actual_artwork_expression_awaits_api_with_confirmed_argument_order(live):
|
|
values = {"appid": 3000000001, "accountName": "active-user", "base64Data": "aW1hZ2U=",
|
|
"extension": "jpg", "assetType": 0}
|
|
result = run_client(live, source=live.ART_WRITE, spec_values=values, entries=[entry(live)])
|
|
assert result["result"] == {"ok": True, "accountName": "active-user", "appid": 3000000001}
|
|
assert [call for call in result["calls"] if call[0] == "SetCustomArtworkForApp"] == [
|
|
["SetCustomArtworkForApp", 3000000001, "aW1hZ2U=", "jpg", 0]]
|
|
assert sum(call[0] == "unregister" for call in result["calls"]) == 2
|
|
|
|
|
|
def test_actual_artwork_expression_rechecks_account_before_mutation(live):
|
|
values = {"appid": 3000000001, "accountName": "another-user", "base64Data": "aW1hZ2U=",
|
|
"extension": "png", "assetType": 3}
|
|
result = run_client(live, source=live.ART_WRITE, spec_values=values, entries=[entry(live)])
|
|
assert "account changed" in result["error"]
|
|
assert not any(call[0] == "SetCustomArtworkForApp" for call in result["calls"])
|
|
|
|
|
|
def test_live_icon_persists_validated_game_sidecar_and_uses_path_api(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
foreign_before = {path.name: path.read_bytes() for path in foreign.iterdir()}
|
|
result = live._live_icon(home, game, 3000000001)
|
|
target = game / ".installer-artwork" / shortcut.ICON_NAME
|
|
assert result == {"status": "added", "path": str(target)}
|
|
assert hashlib.sha256(target.read_bytes()).hexdigest() == shortcut.ICON_SHA256
|
|
assert len(client.icon_writes) == 1 and not client.writes
|
|
assert client.icon_writes[0]["iconPath"] == str(target)
|
|
persisted = (grid.parent / "shortcuts.vdf").read_bytes()
|
|
_, _, selected = shortcut._shortcut_icon_field(persisted, 3000000001, str(game / "halo"))
|
|
assert selected == str(target)
|
|
assert {path.name: path.read_bytes() for path in foreign.iterdir()} == foreign_before
|
|
again = live._live_icon(home, game, 3000000001)
|
|
assert again["status"] == "unchanged" and len(client.icon_writes) == 1
|
|
|
|
|
|
def test_live_icon_preserves_existing_selection_without_publishing_sidecar(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
persisted = grid.parent / "shortcuts.vdf"
|
|
root = shortcut.loads(persisted.read_bytes())
|
|
root["shortcuts"].value["0"].value["icon"].value = "/my/custom/icon.png"
|
|
before = shortcut.dumps(root)
|
|
persisted.write_bytes(before)
|
|
result = live._live_icon(home, game, 3000000001)
|
|
assert result["status"] == "preserved"
|
|
assert persisted.read_bytes() == before and not client.icon_writes
|
|
assert not (game / ".installer-artwork").exists()
|
|
|
|
|
|
def test_live_icon_account_change_before_api_never_changes_selection(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
before = (grid.parent / "shortcuts.vdf").read_bytes()
|
|
client.switch_before_write = True
|
|
with pytest.raises(live.LiveSteamError, match="account changed"):
|
|
live._live_icon(home, game, 3000000001)
|
|
assert not client.icon_writes and (grid.parent / "shortcuts.vdf").read_bytes() == before
|
|
|
|
|
|
def test_live_icon_corrupt_source_is_rejected_before_client_requests(live, artwork_client, tmp_path):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
source = tmp_path / "corrupt-icon"
|
|
source.mkdir()
|
|
(source / shortcut.ICON_NAME).write_bytes(b"bad bundled icon")
|
|
with pytest.raises(live.LiveSteamError, match="integrity"):
|
|
live._live_icon(home, game, 3000000001, source)
|
|
assert not client.calls and not client.icon_writes and not (game / ".installer-artwork").exists()
|
|
|
|
|
|
def test_live_icon_foreign_persisted_executable_is_never_changed(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
persisted = grid.parent / "shortcuts.vdf"
|
|
root = shortcut.loads(persisted.read_bytes())
|
|
root["shortcuts"].value["0"].value["Exe"].value = '"/other/game"'
|
|
before = shortcut.dumps(root)
|
|
persisted.write_bytes(before)
|
|
with pytest.raises(ValueError, match="identity"):
|
|
live._live_icon(home, game, 3000000001)
|
|
assert persisted.read_bytes() == before and not client.icon_writes
|
|
assert not (game / ".installer-artwork").exists()
|
|
|
|
|
|
def test_live_icon_unverified_client_selection_is_kept_and_reported_manual(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
client.corrupt_write = True
|
|
with pytest.raises(live.LiveSteamError, match="persisted"):
|
|
live._live_icon(home, game, 3000000001)
|
|
_, _, selected = shortcut._shortcut_icon_field((grid.parent / "shortcuts.vdf").read_bytes(), 3000000001, str(game / "halo"))
|
|
assert selected == "/custom/concurrent.png"
|
|
|
|
|
|
def test_unpersisted_icon_record_does_not_block_library_or_banner_completion(live, artwork_client, monkeypatch):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
(grid.parent / "shortcuts.vdf").unlink()
|
|
monkeypatch.setattr(live, "_library", lambda *args: {"appid": 3000000001, "created": False})
|
|
result = live.add_native(home, game)
|
|
assert result["status"] == "added" and result["libraryAdded"] is True
|
|
assert result["artwork"]["status"] == "added"
|
|
assert result["artwork"]["icon"]["status"] == "manual"
|
|
assert not client.icon_writes
|
|
|
|
|
|
def test_actual_icon_expression_uses_confirmed_path_setter_without_art_type_four(live):
|
|
values = {"appid": 3000000001, "accountName": "active-user", "iconPath": str(live.GAME / ".installer-artwork/halo-ce-icon.png")}
|
|
result = run_client(live, source=live.ICON_WRITE, spec_values=values, entries=[entry(live)])
|
|
assert result["result"] == {"ok": True, "accountName": "active-user", "appid": 3000000001}
|
|
assert [call for call in result["calls"] if call[0] == "SetShortcutIcon"] == [
|
|
["SetShortcutIcon", 3000000001, values["iconPath"]]]
|
|
assert not any(call[0] == "SetCustomArtworkForApp" for call in result["calls"])
|
|
|
|
|
|
def test_actual_icon_expression_requires_capability_and_checks_account(live):
|
|
values = {"appid": 3000000001, "accountName": "another-user", "iconPath": "/owned/icon.png"}
|
|
result = run_client(live, source=live.ICON_WRITE, spec_values=values, entries=[entry(live)])
|
|
assert "account changed" in result["error"]
|
|
assert not any(call[0] == "SetShortcutIcon" for call in result["calls"])
|
|
result = run_client(live, source=live.ICON_WRITE, spec_values=values, entries=[entry(live)], missing=["SetShortcutIcon"])
|
|
assert "unavailable" in result["error"]
|
|
assert not any(call[0] == "SetShortcutIcon" for call in result["calls"])
|
|
|
|
|
|
@pytest.mark.parametrize("icon_data", [None, "resident-custom-icon"])
|
|
def test_actual_icon_expression_keeps_unloaded_or_resident_custom_icon(live, icon_data):
|
|
values = {"appid": 3000000001, "accountName": "active-user", "iconPath": "/owned/icon.png"}
|
|
result = run_client(live, source=live.ICON_WRITE, spec_values=values, entries=[entry(live)], iconData=icon_data)
|
|
assert "confirmed empty" in result["error"]
|
|
assert not any(call[0] == "SetShortcutIcon" for call in result["calls"])
|
|
|
|
|
|
def test_live_icon_resident_custom_icon_wins_over_stale_empty_vdf(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
client.icon_state = "present"
|
|
before = (grid.parent / "shortcuts.vdf").read_bytes()
|
|
result = live._live_icon(home, game, 3000000001)
|
|
assert result["status"] == "preserved"
|
|
assert not client.icon_writes and (grid.parent / "shortcuts.vdf").read_bytes() == before
|
|
assert not (game / ".installer-artwork").exists()
|
|
|
|
|
|
def test_live_icon_unknown_resident_state_defers_without_sidecar_or_setter(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
client.icon_state = "unknown"
|
|
with pytest.raises(live.LiveSteamError, match="has not loaded"):
|
|
live._live_icon(home, game, 3000000001)
|
|
assert not client.icon_writes and not (game / ".installer-artwork").exists()
|
|
|
|
|
|
def test_live_icon_requests_missing_data_once_and_waits_for_confirmed_empty(live, artwork_client):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
client.icon_state = "unknown"
|
|
client.load_icon = True
|
|
result = live._live_icon(home, game, 3000000001)
|
|
assert result["status"] == "added" and len(client.icon_writes) == 1
|
|
|
|
|
|
def test_actual_icon_session_requests_undefined_data_without_assuming_empty(live):
|
|
values = {"appid": 3000000001, "accountName": "active-user", "requestIcon": True}
|
|
result = run_client(live, source=live.ICON_SESSION, spec_values=values, entries=[entry(live)], iconUnloaded=True, loadedIconData="")
|
|
assert result["result"]["iconState"] == "unknown" and result["result"]["iconRequested"] is True
|
|
assert [call for call in result["calls"] if call[0] == "RequestIconDataForApp"] == [["RequestIconDataForApp", 3000000001]]
|
|
assert not any(call[0] == "SetShortcutIcon" for call in result["calls"])
|
|
|
|
|
|
def test_corrupt_icon_prevents_all_live_artwork_calls(live, artwork_client, tmp_path):
|
|
home, game, grid, foreign, login, client, shortcut = artwork_client
|
|
source = tmp_path / "corrupt-icon-with-valid-art"
|
|
source.mkdir()
|
|
for _, _, filename, _ in shortcut.ARTWORK:
|
|
(source / filename).write_bytes((RESOURCE.parent / "artwork" / filename).read_bytes())
|
|
(source / shortcut.ICON_NAME).write_bytes(b"corrupt final icon")
|
|
with pytest.raises(live.LiveSteamError, match="icon.*integrity"):
|
|
live._live_artwork(home, game, 3000000001, source)
|
|
assert not client.calls and not client.writes and not list(grid.iterdir())
|