Files
DroidDeck/tools/tests/test_steam_install.py
T
ovurrsl 7aeb7779bb fix: fall back to Valve's stable Steam client when the beta one keeps failing
The native arm64 client follows steamdeck_publicbeta, so a Valve beta
regression reaches every user at once. If the repaired beta client is still
started twice without being shown, or the beta manifest carries no arm64
client, droiddeck-steam-install now installs steamdeck_stable, keeps it, and
logs why (listing the manifest's blocks in the second case). The session asks
the installer for the channel, so -clientbeta and package/beta stay in sync.
BL_STEAM_CHANNEL or ~/.config/droiddeck/steam-channel (beta|stable) pins it.

Valve's 2026-10-15 beta moved the x86 client into a Steam Runtime container.
In case the arm64 client follows, its session now exports
STEAM_STEAMRT_RECURSION_GUARD=1 and, only if steamrtarm64 carries pv-runtime
or a steam-runtime-check-requirements probe, starts it through the same
steamrt shim the x86-64 client uses (now droiddeck-steamrt-shim) and logs it.
2026-10-23 12:03:41 -04:00

261 lines
12 KiB
Python

import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
import zipfile
SCRIPT = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-steam-install'
class SteamInstallTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.home = self.root / 'home'
self.home.mkdir()
self.data = self.root / 'data'
self.steam = self.data / 'Steam'
self.cdn = self.root / 'cdn'
self.cdn.mkdir()
self.requests = self.root / 'requests'
self.bin = self.root / 'bin'
self.bin.mkdir()
curl = self.bin / 'curl'
curl.write_text('''#!/bin/bash
set -eu
url=""
out=""
while [ $# -gt 0 ]; do
case $1 in
-o) out=$2; shift 2 ;;
--connect-timeout|--max-time|--speed-limit|--speed-time|--retry|--retry-delay) shift 2 ;;
-*) shift ;;
*) url=$1; shift ;;
esac
done
printf '%s\\n' "$url" >> "$TEST_REQUESTS"
if [ -n "${TEST_FAIL_HOST:-}" ]; then
case $url in *"$TEST_FAIL_HOST"*) exit 22 ;; esac
fi
cp "$TEST_CDN/${url##*/}" "$out"
''')
curl.chmod(0o755)
if shutil.which('sha256sum') is None:
sha = self.bin / 'sha256sum'
sha.write_text('#!/bin/bash\nexec shasum -a 256 "$@"\n')
sha.chmod(0o755)
if shutil.which('sha1sum') is None:
sha1 = self.bin / 'sha1sum'
sha1.write_text('#!/bin/bash\nexec shasum -a 1 "$@"\n')
sha1.chmod(0o755)
self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.data),
PATH=str(self.bin) + os.pathsep + os.environ['PATH'],
TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests))
for channel in ('steamdeck_publicbeta', 'steamdeck_stable'):
archive = self.cdn / (channel + '.zip')
with zipfile.ZipFile(archive, 'w') as package:
package.writestr('steamrtarm64/steam', channel)
package.writestr('linuxarm64/wrapper', 'wrapper')
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
(self.cdn / ('steam_client_' + channel + '_linuxarm64')).write_text(
'"linuxarm64"\n{\n\t"version" "123456"\n'
'\t"client_linuxarm64_linuxarm64"\n\t{\n'
f'\t\t"file" "{archive.name}"\n\t\t"sha2" "{digest}"\n'
'\t}\n}\n')
def install(self, fail_host=None, *args, **extra):
env = self.env if fail_host is None else dict(self.env, TEST_FAIL_HOST=fail_host)
return subprocess.run(['bash', str(SCRIPT), *args], env=dict(env, **extra), text=True, capture_output=True)
def channel(self, **extra):
result = self.install(None, '--channel', **extra)
self.assertEqual(0, result.returncode, result.stderr)
return result.stdout.strip()
def assert_installed(self, channel='steamdeck_publicbeta', fail_host=None, expected=None, **extra):
result = self.install(fail_host, **extra)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual(channel + '\n', (self.steam / 'package/beta').read_text())
self.assertEqual('123456\n', (self.steam / 'package/droiddeck-installed').read_text())
client = self.steam / 'steamrtarm64/steam'
self.assertTrue(os.access(client, os.X_OK))
self.assertEqual(channel, client.read_text())
self.assertEqual((self.steam / 'linuxarm64').resolve(), (self.home / '.steam/sdkarm64').resolve())
if expected is None:
expected = [
'https://client-update.steamstatic.com/steam_client_' + channel + '_linuxarm64',
'https://client-update.steamstatic.com/' + channel + '.zip',
]
self.assertEqual(expected, self.requests.read_text().splitlines())
return result
def test_downloads_the_deck_beta_client(self):
self.assert_installed()
def test_a_cdn_that_does_not_answer_hands_over_to_the_next_one(self):
akamai = 'https://client-update.akamai.steamstatic.com'
self.assert_installed(fail_host='client-update.steamstatic.com', expected=[
'https://client-update.steamstatic.com/steam_client_steamdeck_publicbeta_linuxarm64',
akamai + '/steam_client_steamdeck_publicbeta_linuxarm64',
akamai + '/steamdeck_publicbeta.zip',
])
def test_an_install_from_another_branch_is_left_for_steam_to_move(self):
self.assert_installed()
requests = self.requests.read_text()
(self.steam / 'package/beta').write_text('publicbeta\n')
result = self.install()
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual(requests, self.requests.read_text())
self.assertEqual('publicbeta\n', (self.steam / 'package/beta').read_text())
def test_failed_download_has_no_ready_marker_and_can_retry(self):
archive = self.cdn / 'steamdeck_publicbeta.zip'
original = archive.read_bytes()
archive.write_bytes(b'corrupt download')
self.assertNotEqual(0, self.install().returncode)
self.assertFalse((self.steam / 'package/droiddeck-installed').exists())
archive.write_bytes(original)
self.assertEqual(0, self.install().returncode)
self.assertEqual('steamdeck_publicbeta\n', (self.steam / 'package/beta').read_text())
def break_client(self, starts):
self.assert_installed()
self.requests.unlink()
package = self.steam / 'package'
(self.steam / 'steamrtarm64/steam').write_text('half updated')
(package / 'steam_client_steamdeck_publicbeta_linuxarm64.manifest').write_text('manifest')
(package / 'steam_client_steamdeck_publicbeta_linuxarm64.installed').write_text('installed')
good = b'complete package'
(package / ('bins_linuxarm64.zip.vz.' + hashlib.sha1(good).hexdigest() + '_16')).write_bytes(good)
(package / ('bins_misc_linuxarm64.zip.vz.' + hashlib.sha1(b'whole').hexdigest() + '_5')).write_bytes(b'wh')
(package / ('strings_all.zip.' + hashlib.sha1(b'strings').hexdigest())).write_bytes(b'strings')
(self.steam / '.crash').write_text('')
(package / 'droiddeck-unconfirmed').write_text('start\n' * starts)
return package
def test_two_starts_with_nothing_shown_fetch_the_client_again(self):
package = self.break_client(2)
self.assert_installed()
self.assertEqual('repaired\n', (package / 'droiddeck-unconfirmed').read_text())
self.assertFalse((self.steam / '.crash').exists())
self.assertEqual([], sorted(p.name for p in package.glob('steam_client_*')))
self.assertEqual(['bins_linuxarm64.zip.vz.', 'strings_all.zip.'],
sorted(p.name[:p.name.rindex('.') + 1] for p in package.glob('*.zip.*')))
def test_one_start_with_nothing_shown_is_left_alone(self):
package = self.break_client(1)
self.assertEqual(0, self.install().returncode)
self.assertFalse(self.requests.exists())
self.assertEqual('half updated', (self.steam / 'steamrtarm64/steam').read_text())
self.assertTrue((package / 'steam_client_steamdeck_publicbeta_linuxarm64.installed').exists())
def test_a_repair_is_not_repeated_until_the_client_has_been_seen(self):
package = self.break_client(2)
self.assertEqual(0, self.install().returncode)
self.requests.unlink()
with (package / 'droiddeck-unconfirmed').open('a') as marker:
marker.write('start\nstart\nstart\n')
self.assertEqual(0, self.install(BL_STEAM_CHANNEL='beta').returncode)
self.assertFalse(self.requests.exists())
def test_the_default_channel_is_the_deck_beta(self):
self.assertEqual('steamdeck_publicbeta', self.channel())
def stable_requests(self, beta_manifest=True):
stable = [
'https://client-update.steamstatic.com/steam_client_steamdeck_stable_linuxarm64',
'https://client-update.steamstatic.com/steamdeck_stable.zip',
]
if beta_manifest:
stable.insert(0, 'https://client-update.steamstatic.com/steam_client_steamdeck_publicbeta_linuxarm64')
return stable
def test_a_repaired_beta_client_that_still_shows_nothing_falls_back_to_stable(self):
package = self.break_client(2)
self.assertEqual(0, self.install().returncode)
self.requests.unlink()
with (package / 'droiddeck-unconfirmed').open('a') as marker:
marker.write('start\nstart\n')
result = self.assert_installed('steamdeck_stable', expected=self.stable_requests(False))
self.assertIn("falling back to Valve's steamdeck_stable Steam client", result.stdout)
self.assertEqual('steamdeck_stable\n', (package / 'droiddeck-channel').read_text())
self.assertEqual('repaired\n', (package / 'droiddeck-unconfirmed').read_text())
self.assertEqual('steamdeck_stable', self.channel())
def test_the_stable_fallback_is_not_repeated(self):
self.test_a_repaired_beta_client_that_still_shows_nothing_falls_back_to_stable()
self.requests.unlink()
with (self.steam / 'package/droiddeck-unconfirmed').open('a') as marker:
marker.write('start\nstart\nstart\n')
self.assertEqual(0, self.install().returncode)
self.assertFalse(self.requests.exists())
self.assertEqual('steamdeck_stable\n', (self.steam / 'package/beta').read_text())
def test_a_pinned_beta_channel_never_falls_back(self):
package = self.break_client(2)
self.assertEqual(0, self.install().returncode)
with (package / 'droiddeck-unconfirmed').open('a') as marker:
marker.write('start\nstart\n')
self.assertEqual('steamdeck_publicbeta', self.channel(BL_STEAM_CHANNEL='beta'))
self.assertEqual(0, self.install(BL_STEAM_CHANNEL='beta').returncode)
self.assertFalse((package / 'droiddeck-channel').exists())
def test_the_channel_setting_selects_stable(self):
setting = self.home / '.config/droiddeck/steam-channel'
setting.parent.mkdir(parents=True)
setting.write_text('stable\n')
self.assertEqual('steamdeck_stable', self.channel())
self.assert_installed('steamdeck_stable', expected=self.stable_requests(False))
self.assertFalse((self.steam / 'package/droiddeck-channel').exists())
def empty_beta_manifest(self):
(self.cdn / 'steam_client_steamdeck_publicbeta_linuxarm64').write_text(
'"linuxarm64"\n{\n\t"version" "123457"\n'
'\t"client_steamrtarm64_linuxarm64"\n\t{\n'
'\t\t"file" "client.zip"\n\t\t"sha2" "00"\n'
'\t}\n}\n')
def test_a_beta_manifest_without_an_arm64_client_falls_back_to_stable(self):
self.empty_beta_manifest()
result = self.assert_installed('steamdeck_stable', expected=self.stable_requests())
self.assertIn('no client components in steam_client_steamdeck_publicbeta_linuxarm64', result.stderr)
self.assertIn('client_steamrtarm64_linuxarm64', result.stderr)
self.assertEqual('steamdeck_stable\n', (self.steam / 'package/droiddeck-channel').read_text())
self.assertEqual('steamdeck_stable', self.channel())
def test_a_pinned_beta_manifest_without_an_arm64_client_fails_and_names_its_blocks(self):
self.empty_beta_manifest()
result = self.install(BL_STEAM_CHANNEL='beta')
self.assertNotEqual(0, result.returncode)
self.assertIn('client_steamrtarm64_linuxarm64', result.stderr)
self.assertFalse((self.steam / 'package/droiddeck-installed').exists())
def test_requested_repair_runs_once_and_is_retried_until_it_completes(self):
package = self.break_client(0)
(package / 'droiddeck-unconfirmed').unlink()
request = self.home / '.bl-steam-repair'
request.write_text('1\n')
archive = self.cdn / 'steamdeck_publicbeta.zip'
original = archive.read_bytes()
archive.write_bytes(b'corrupt download')
self.assertNotEqual(0, self.install().returncode)
self.assertTrue(request.exists())
self.assertFalse((package / 'droiddeck-installed').exists())
archive.write_bytes(original)
self.requests.unlink()
self.assert_installed()
self.assertFalse(request.exists())
self.requests.unlink()
self.assertEqual(0, self.install().returncode)
self.assertFalse(self.requests.exists())
if __name__ == '__main__':
unittest.main()