Files
spoopyghosty0--frameport/tests/test_screenshots.py
T

280 lines
13 KiB
Python

"""Screenshots tab: the agent's listing (screenshots.vdf + folders, games matched by play session), its delete path
checks, the launchers' play log, and the PC-side cache/download helpers."""
import contextlib
import json
import os
import shutil
import subprocess
import sys
import time
import pytest
from test_agent import load_agent
VDF = """"screenshots"
{
\t"250820"
\t{
\t\t"0"
\t\t{
\t\t\t"type"\t\t"1"
\t\t\t"filename"\t\t"250820/screenshots/20260928184431_1.jpg"
\t\t\t"thumbnail"\t\t"250820/screenshots/thumbnails/20260928184431_1.jpg"
\t\t\t"imported"\t\t"1"
\t\t\t"externalfilename"\t\t"/run/user/1000/Screenshot_Mon_Sep_28_18-44-31_2026.png"
\t\t\t"width"\t\t"1920"
\t\t\t"height"\t\t"1080"
\t\t\t"gameid"\t\t"250820"
\t\t\t"creation"\t\t"1790635471"
\t\t}
\t\t"1"
\t\t{
\t\t\t"filename"\t\t"250820/screenshots/20260927100000_1.jpg"
\t\t\t"creation"\t\t"1790500000"
\t\t}
\t\t"2"
\t\t{
\t\t\t"filename"\t\t"250820/screenshots/20260101000000_1.jpg"
\t\t\t"creation"\t\t"1700000000"
\t\t}
\t\t"3"
\t\t{
\t\t\t"filename"\t\t"../../../../config/escape.jpg"
\t\t}
\t}
}
"""
def make_tree(a, home):
user = home / ".local/share/Steam/userdata/123"
shots = user / "760/remote/250820/screenshots"
(shots / "thumbnails").mkdir(parents=True)
(user / "760/screenshots.vdf").write_text(VDF)
for name in ("20260928184431_1.jpg", "20260927100000_1.jpg"):
(shots / name).write_bytes(b"\xff\xd8" + b"x" * 100)
(shots / "thumbnails/20260928184431_1.jpg").write_bytes(b"\xff\xd8thumb")
# 20260101000000_1.jpg is listed but deleted; one file isn't in the vdf yet (Steam writes it at exit)
(shots / "20260929120000_1.jpg").write_bytes(b"\xff\xd8" + b"y" * 50)
(user / "config").mkdir()
# a FramePort game installed with its own Steam shortcut + plays.log sessions
anchor = home / "Applications/quest-frame/com.x.game"
anchor.mkdir(parents=True)
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.game", "title": "X Game",
"appid": 3000000001, "base": str(anchor)}))
(anchor / "plays.log").write_text("start 1790635000\nend 1790636000\nstart 1790499000\n")
other = home / "Applications/quest-frame/com.y.other"
other.mkdir(parents=True)
(other / "deployment.json").write_text(json.dumps({"package": "com.y.other", "title": "Other", "appid": 5}))
(other / "plays.log").write_text("start 1790499500\nend 1790500100\n")
# a screenshot filed under a FramePort game's own shortcut appid
own = user / "760/remote/3000000001/screenshots"
own.mkdir(parents=True)
(own / "20260930080000_1.jpg").write_bytes(b"\xff\xd8z")
return user
def test_kv_parse(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
d = a.kv_parse(VDF)
assert d["screenshots"]["250820"]["0"]["creation"] == "1790635471"
assert a.kv_parse('"a" { "b" "x\\"y" // c\n "c" { "d" "1"')["a"] == {"b": 'x"y', "c": {"d": "1"}} # cut off
assert a.kv_parse("") == {}
def test_list_screenshots_attributes_by_session(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
make_tree(a, tmp_path)
r = a.cmd_list_screenshots({})
names = [os.path.basename(s["path"]) for s in r["shots"]]
assert names == ["20260930080000_1.jpg", "20260929120000_1.jpg", "20260928184431_1.jpg",
"20260927100000_1.jpg"] # newest first; the deleted and the escaping entry are left out
by = {os.path.basename(s["path"]): s for s in r["shots"]}
# inside X Game's closed session
assert by["20260928184431_1.jpg"]["package"] == "com.x.game"
assert by["20260928184431_1.jpg"]["title"] == "X Game" and by["20260928184431_1.jpg"]["width"] == 1920
assert by["20260928184431_1.jpg"]["thumb"].endswith("thumbnails/20260928184431_1.jpg")
# X Game's open session (no end) was ended by Other's later start, so this one is Other's
assert by["20260927100000_1.jpg"]["package"] == "com.y.other"
# not in the vdf: timed by its name, outside every session -> SteamVR
assert by["20260929120000_1.jpg"]["package"] is None and by["20260929120000_1.jpg"]["title"] == "SteamVR"
assert by["20260929120000_1.jpg"]["time"] == int(time.mktime((2026, 9, 29, 12, 0, 0, 0, 0, -1)))
assert by["20260929120000_1.jpg"]["thumb"] is None
# filed under the game's own shortcut
assert by["20260930080000_1.jpg"]["package"] == "com.x.game" and by["20260930080000_1.jpg"]["account"] == "123"
assert r["total"] == 4
assert {(g["package"], g["count"]) for g in r["games"]} == {("com.x.game", 2), ("com.y.other", 1), (None, 1)}
page = a.cmd_list_screenshots({"package": "com.x.game", "offset": 1, "limit": 5})
assert page["total"] == 2 and [s["path"] for s in page["shots"]] == [by["20260928184431_1.jpg"]["path"]]
assert [s["title"] for s in a.cmd_list_screenshots({"package": ""})["shots"]] == ["SteamVR"]
def test_list_screenshots_without_steam(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
assert a.cmd_list_screenshots({}) == {"shots": [], "total": 0, "games": []}
def test_open_session_ends_after_a_while(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
anchor = tmp_path / "Applications/quest-frame/com.x.game"
anchor.mkdir(parents=True)
(anchor / "plays.log").write_text("start 1000\ngarbage\nend x\n")
sessions = a.play_sessions()
assert sessions == [(1000, 1000 + a.OPEN_SESSION, "com.x.game")]
assert a.session_game(1500, sessions) == "com.x.game" and a.session_game(999, sessions) is None
assert a.session_game(1000 + a.OPEN_SESSION + 60, sessions) is None
def test_delete_screenshots_only_in_screenshot_folders(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
user = make_tree(a, tmp_path)
shots = user / "760/remote/250820/screenshots"
target = str(shots / "20260928184431_1.jpg")
bad = ["/etc/passwd", str(user / "760/screenshots.vdf"), str(shots / "thumbnails/20260928184431_1.jpg"),
str(shots / "../screenshots/20260928184431_1.jpg"), str(shots / "notashot.jpg"), "relative.jpg",
str(shots / "20260101000000_1.jpg"), 42]
(shots / "notashot.jpg").write_bytes(b"x")
for p in bad:
with pytest.raises(a.AgentError):
a.cmd_delete_screenshots({"paths": [target, p]})
assert os.path.exists(target) # nothing deleted when any path is refused
outside = tmp_path / "secret"
outside.mkdir()
(outside / "20260101000000_1.jpg").write_bytes(b"s")
link_dir = user / "760/remote/999/screenshots"
link_dir.parent.mkdir(parents=True)
link_dir.symlink_to(outside)
with pytest.raises(a.AgentError):
a.cmd_delete_screenshots({"paths": [str(link_dir / "20260101000000_1.jpg")]})
assert (outside / "20260101000000_1.jpg").exists()
(shots / "20260928184431_1_vr.jpg").write_bytes(b"stereo") # Steam's stereo copy of a VR screenshot
r = a.cmd_delete_screenshots({"paths": [target]})
assert r == {"deleted": [target]}
assert not os.path.exists(target) and not (shots / "thumbnails/20260928184431_1.jpg").exists()
assert not (shots / "20260928184431_1_vr.jpg").exists()
assert "20260928184431_1.jpg" in (user / "760/screenshots.vdf").read_text() # Steam's index is left alone
assert target not in [s["path"] for s in a.cmd_list_screenshots({})["shots"]]
@pytest.mark.skipif(sys.platform == "win32", reason="bash launcher")
def test_launcher_logs_play_sessions(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
base, anchor = tmp_path / "base", tmp_path / "anchor dir"
(base / "lepton-app").mkdir(parents=True)
anchor.mkdir()
lepton = tmp_path / "lepton"
lepton.write_text("#!/bin/bash\nexit 0\n")
lepton.chmod(0o755)
a.write_launcher(str(anchor), str(base), "com.x.y", "X", 123, str(lepton), {"FRAMEPORT_KEEP_DASHBOARD": "1"})
env = {**os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}"}
(tmp_path / "podman").write_text("#!/bin/sh\nexit 0\n")
(tmp_path / "podman").chmod(0o755)
subprocess.run(["bash", str(anchor / "launch.sh")], env=env, timeout=30, capture_output=True)
lines = (anchor / "plays.log").read_text().split("\n")
assert lines[0].startswith("start ") and lines[1].startswith("end ") and lines[2] == ""
assert int(lines[0].split()[1]) <= int(lines[1].split()[1])
def test_upgrade_launchers_adds_play_log(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
root = tmp_path / "Applications/quest-frame"
quest, pcvr = root / "com.x.y", root / "rift.z"
quest.mkdir(parents=True)
pcvr.mkdir(parents=True)
a.write_launcher(str(quest), "/b", "com.x.y", "X", 1, "/lepton", {})
old = "\n".join(line for line in (quest / "launch.sh").read_text().split("\n") if "plays.log" not in line)
(quest / "launch.sh").write_text(old)
(pcvr / "launch.sh").write_text('#!/bin/bash\necho hi >"$base/launch.log"\nexec wine game.exe\n')
assert sorted(a.upgrade_launchers()) == ["com.x.y", "rift.z"]
q = (quest / "launch.sh").read_text()
assert q.count("plays.log") == 2 and q.index('"start $(date') < q.index("\nsetsid ")
assert ' echo "end $(date +%s)"' in q
assert subprocess.run(["bash", "-n", str(quest / "launch.sh")]).returncode == 0
p = (pcvr / "launch.sh").read_text()
assert p.count("plays.log") == 1 and p.index("plays.log") < p.index("\nexec ")
assert a.upgrade_launchers() == []
# ------------------------------------------------------------------ PC side
class FakeSFTP:
def __init__(self):
self.gets = []
def get(self, remote, local, callback=None):
self.gets.append(remote)
shutil.copyfile(remote, local)
if callback:
callback(os.path.getsize(remote), os.path.getsize(remote))
class FakeFrame:
def __init__(self, agent_mod):
self.sftp = FakeSFTP()
self.a = agent_mod
self.target = type("T", (), {"host": "10.0.0.5", "name": ""})()
def agent(self, command, **args):
return self.a.COMMANDS[command](args)
@pytest.fixture
def pc(monkeypatch, tmp_path):
from frameport.install import installer
monkeypatch.setattr(installer, "transfer_link", lambda f, r, n: contextlib.nullcontext(f))
frame_home = tmp_path / "frame"
frame_home.mkdir()
a = load_agent(monkeypatch, frame_home)
make_tree(a, frame_home)
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path / "pc"))
return FakeFrame(a)
def test_pc_cache_and_download(pc, tmp_path):
from frameport.core.paths import user_data_dir
from frameport.install import screenshots
r = screenshots.list_shots(pc, package="com.x.game")
assert r["total"] == 2
shot = next(s for s in r["shots"] if s["thumb"])
assert screenshots.cached(pc, shot) is None
t = screenshots.thumb_path(pc, shot)
assert t.read_bytes() == b"\xff\xd8thumb" and t.is_relative_to(user_data_dir() / "screenshots-cache" / "10.0.0.5")
assert screenshots.thumb_path(pc, shot) == t and pc.sftp.gets == [shot["thumb"]] # downloaded once
assert screenshots.cached(pc, shot) == t
full = screenshots.image_path(pc, shot)
assert full.read_bytes() == open(shot["path"], "rb").read()
# without a Steam thumbnail one is made from the full image (here: not an image -> the original is used)
no_thumb = next(s for s in r["shots"] if not s["thumb"])
assert screenshots.thumb_path(pc, no_thumb).exists()
out = tmp_path / "out"
got = screenshots.download(pc, r["shots"], out)
assert got["files"] == 2 and (out / "X Game" / os.path.basename(shot["path"])).exists()
assert screenshots.download(pc, r["shots"], out)["skipped"] == 2 # same size: not copied again
assert screenshots.delete(pc, [shot]) == 1 and not os.path.exists(shot["path"])
assert screenshots.cached(pc, shot) is None
def test_pc_download_cancel(pc, tmp_path):
from frameport.core.events import Cancelled, Reporter
from frameport.install import screenshots
rep = Reporter()
rep.cancelled.set()
with pytest.raises(Cancelled):
screenshots.download(pc, screenshots.list_shots(pc)["shots"], tmp_path / "out", rep)
assert not list((tmp_path / "out").rglob("*.jpg"))
def test_group_by_day():
from frameport.ui.views.screenshots import agent_filter, filter_key, group_by_day
now = time.mktime((2026, 10, 4, 15, 0, 0, 0, 0, -1))
shots = [{"time": now - 60}, {"time": now - 3600}, {"time": now - 86400}, {"time": now - 5 * 86400},
{"time": None}]
groups = group_by_day(shots, now)
assert [(label, len(s)) for label, s in groups] == [("Today", 2), ("Yesterday", 1), ("2026-09-29", 1),
("Unknown date", 1)]
assert agent_filter(filter_key(None)) is None and agent_filter(filter_key("")) == ""
assert agent_filter(filter_key("com.x")) == "com.x"