Files
saphid--frame-control/tests/e2e/test_running.py
T
SAPHIDandClaude Opus 5.5 a5980d8f98 Force quit a running game from Games and the Control Center
Running now, at the top of Games, lists what's running on the Frame (Steam
games, sideloaded titles, non-Steam shortcuts, Android apps), with Force quit
on each, on a game's library tile and in a sideloaded title's menu. The
Control Center gets a Force quit a game card. Each asks first: unsaved
progress will be lost. Nothing running says so.

ui/frame_running.py finds games by Steam's `reaper SteamLaunch AppId=<id>`
process and its tree, never by name. It asks Steam to quit
(SteamClient.Apps.TerminateApp), then sends SIGTERM and SIGKILL to the tree,
skipping init, the SSH session, other users and Steam, gamescope, SteamVR,
sshd and the session. Android apps also get the Apps tab's container stop.

The fake Frame runs games under a stand-in reaper and lists them in
SteamUIStore.RunningApps, so tests/e2e/test_running.py force quits real
process trees. Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:17:59 +00:00

83 lines
4.0 KiB
Python

"""Force quit (ui/frame_running.py) through the server's HTTP API, against games the
fake Steam client really runs under its stand-in reaper (fakesteam.REAPER)."""
import os
import unittest
import harness
from harness import api, install_title, launches, ok, ssh, state, wait_for
harness.require()
class Running(harness.FrameTestCase):
def script_title(self, name, body):
folder = self.path(name)
os.makedirs(folder)
with open(os.path.join(folder, 'run.sh'), 'w') as f:
f.write('#!/bin/sh\n' + body)
os.chmod(os.path.join(folder, 'run.sh'), 0o755)
plan, job = install_title(folder)
self.assertIsNone(job['error'], job)
return plan['id'], plan['name']
def running(self, match):
return next((g for g in ok('GET', '/api/running')['running'] if match(g)), None)
def launch_title(self, gid):
ok('POST', '/api/titles', {'action': 'launch', 'id': gid})
game = wait_for(lambda: self.running(lambda g: g.get('title') == gid), 20, f'{gid} to be running')
# The fake's reset doesn't stop programs: don't leave one running for the next test.
self.addCleanup(ssh, f"pkill -KILL -f '[d]evkit-game/{gid}/' || true", check=False)
return game
def assert_steam_alive(self, pid):
self.assertEqual(ssh(f'kill -0 {pid} && echo alive').strip(), 'alive')
def test_nothing_running(self):
self.assertEqual(ok('GET', '/api/running'), {'running': [], 'message': 'Nothing is running on the Frame'})
status, out, _ = api('POST', '/api/running', {'action': 'quit', 'appid': '620'})
self.assertEqual(status, 409, out)
def test_sigterm_quits_a_sideloaded_game(self):
gid, name = self.script_title('Hang Game', 'sleep 600 &\nwait\n')
steam = ssh('cat ~/.steam/steam.pid').strip()
game = self.launch_title(gid)
self.assertEqual((game['kind'], game['name']), ('devkit', name))
self.assertGreaterEqual(game['processes'], 3) # reaper, run.sh, sleep
out = ok('POST', '/api/running', {'action': 'quit', 'appid': str(game['appid'])})
self.assertEqual(out['method'], 'term', out)
self.assertEqual(out['message'], f'Force quit {name}')
# Steam was asked first, with the shortcut's game id.
self.assertEqual(state()['steam']['last_terminate'], game['gameid'])
self.assertIsNone(self.running(lambda g: g['appid'] == game['appid']))
self.assertEqual(ssh("pgrep -fc '[s]leep 600' || true").strip(), '0')
self.assert_steam_alive(steam)
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
10, 'the reaper to report the exit')
self.assertIsNotNone(done['exit'])
def test_sigkill_when_it_ignores_sigterm(self):
gid, name = self.script_title('Stuck Game', "trap '' TERM\nsleep 600 &\nwait\n")
steam = ssh('cat ~/.steam/steam.pid').strip()
game = self.launch_title(gid)
out = ok('POST', '/api/running', {'action': 'quit', 'appid': str(game['appid'])})
self.assertEqual(out['method'], 'kill', out)
self.assertEqual(out['message'], f'Force quit {name} (it had to be killed)')
self.assertIsNone(self.running(lambda g: g['appid'] == game['appid']))
self.assertEqual(ssh("pgrep -fc '[s]leep 600' || true").strip(), '0')
self.assert_steam_alive(steam)
def test_steam_game_quits_through_steam(self):
ok('POST', '/api/launch', {'appid': 620980})
game = wait_for(lambda: self.running(lambda g: g['appid'] == 620980), 20, 'the Steam game to be running')
self.assertEqual((game['kind'], game['gameid']), ('steam', '620980'))
out = ok('POST', '/api/running', {'action': 'quit', 'appid': '620980'})
self.assertEqual(out['method'], 'steam', out)
self.assertEqual(state()['steam']['last_terminate'], '620980')
self.assertEqual(launches('rungameid')[-1]['exit'], 'terminated')
self.assertEqual(ok('GET', '/api/running')['running'], [])
if __name__ == '__main__':
unittest.main()