mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
Fix the review's findings in the test harness
Headset smoke test: drop the paired key from authorized_keys with a same-mode copy swapped in, so a failed write can't truncate it; check the throwaway key with no ssh_config or agent; clean up idempotently (tracked and leftover titles, their json files, Steam's shortcuts via steamos-delete, and ~/devkit-utils if it wasn't there before), with a failed cleanup a failed step; count a launch only with fresh evidence (the process, Steam's log, or the known missing-runtime line), matching with [d]evkit-game so pgrep doesn't find its own shell. The test programs sleep 10 s. Fake Frame: log a launch before its reaper can look for it. e2e: kill a pairing client's process group when a test ends; accept an aarch64 program running under QEMU on x86 hosts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0181071b83
commit
93aebb5019
6 files changed
+173
-66
No files matched your search
+11
-3
@@ -111,9 +111,17 @@ scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the
|
|||||||
|
|
||||||
It checks `properties.json` and the status, then installs, launches and
|
It checks `properties.json` and the status, then installs, launches and
|
||||||
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
|
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
|
||||||
(an ARM64 and an x86-64 static Linux program that sleep for five seconds, and
|
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
|
||||||
an x86-64 `.exe` that exits at once), keeping what Steam logged about each.
|
an x86-64 `.exe` that exits at once). A launch passes only with fresh evidence:
|
||||||
Everything it installs is removed again, also after a failure. Results go to
|
the ARM64 program running, the `.exe` started (its process or Steam's log),
|
||||||
|
and the x86-64 program running or Steam logging that its runtime isn't
|
||||||
|
installed, which is what the Frame does today. Steam's log lines about each
|
||||||
|
title are kept.
|
||||||
|
|
||||||
|
Everything it installs is removed again, also after a failure: the titles and
|
||||||
|
their Steam shortcuts, a paired key, and `~/devkit-utils` if it wasn't there
|
||||||
|
before (if it was, it stays, synced to this checkout as Frame Control always
|
||||||
|
does). A cleanup that fails counts as a failed step. Results go to
|
||||||
`tests/smoke/results/<time>-<BUILD_ID>.json` (not committed) with a summary on
|
`tests/smoke/results/<time>-<BUILD_ID>.json` (not committed) with a summary on
|
||||||
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
|
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
|
||||||
isn't reachable.
|
isn't reachable.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
steamos-devkit-service on the fake Frame, and its password fallback."""
|
steamos-devkit-service on the fake Frame, and its password fallback."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -28,9 +29,20 @@ class Pairing(harness.FrameTestCase):
|
|||||||
f.write(f'#!/bin/sh\necho {askpass}\n')
|
f.write(f'#!/bin/sh\necho {askpass}\n')
|
||||||
os.chmod(script, stat.S_IRWXU)
|
os.chmod(script, stat.S_IRWXU)
|
||||||
env.update(SSH_ASKPASS=script, SSH_ASKPASS_REQUIRE='force')
|
env.update(SSH_ASKPASS=script, SSH_ASKPASS_REQUIRE='force')
|
||||||
return subprocess.Popen([sys.executable, str(ROOT / 'ui' / 'frame_connect.py'), FAKE_HOST],
|
proc = subprocess.Popen([sys.executable, str(ROOT / 'ui' / 'frame_connect.py'), FAKE_HOST],
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
text=True, env=env, start_new_session=True)
|
text=True, env=env, start_new_session=True)
|
||||||
|
self.addCleanup(self.stop, proc) # a failed test mustn't leave it pairing into the next one
|
||||||
|
return proc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def stop(proc):
|
||||||
|
if proc.poll() is None:
|
||||||
|
try:
|
||||||
|
os.killpg(proc.pid, signal.SIGKILL) # frame_connect.py and its ssh children
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
proc.communicate()
|
||||||
|
|
||||||
def finish(self, proc, timeout=120):
|
def finish(self, proc, timeout=120):
|
||||||
out, _ = proc.communicate(timeout=timeout)
|
out, _ = proc.communicate(timeout=timeout)
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ from harness import HOME, ROOT, ctl, exists, install_title, launches, ok, ssh, s
|
|||||||
harness.require()
|
harness.require()
|
||||||
|
|
||||||
GAMES = f'{HOME}/devkit-game'
|
GAMES = f'{HOME}/devkit-game'
|
||||||
# The host container shares the fake Frame's kernel, so a native program of this
|
# The host container shares the fake Frame's kernel, so a program of this machine's
|
||||||
# machine's architecture really runs there; the other one fails to exec.
|
# architecture really runs there. The other one fails to exec, unless QEMU is
|
||||||
|
# registered with binfmt_misc, which runs it emulated.
|
||||||
NATIVE = {'aarch64': 'arm64', 'arm64': 'arm64', 'x86_64': 'x86_64'}.get(platform.machine())
|
NATIVE = {'aarch64': 'arm64', 'arm64': 'arm64', 'x86_64': 'x86_64'}.get(platform.machine())
|
||||||
|
|
||||||
|
|
||||||
@@ -74,10 +75,8 @@ class Titles(harness.FrameTestCase):
|
|||||||
if NATIVE == 'arm64':
|
if NATIVE == 'arm64':
|
||||||
self.assertIsNotNone(run['pid'], run)
|
self.assertIsNotNone(run['pid'], run)
|
||||||
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
|
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
|
||||||
20, 'the arm64 test program to exit')
|
30, 'the arm64 test program to exit')
|
||||||
self.assertEqual(done['exit'], 0)
|
self.assertEqual(done['exit'], 0)
|
||||||
else:
|
|
||||||
self.assertIn('Exec format error', run.get('exec_error', ''), run)
|
|
||||||
|
|
||||||
def test_single_exe_upload_launch_and_remove(self):
|
def test_single_exe_upload_launch_and_remove(self):
|
||||||
exe = tiny_programs.write(self.tmp, 'exe')
|
exe = tiny_programs.write(self.tmp, 'exe')
|
||||||
@@ -131,7 +130,7 @@ class Titles(harness.FrameTestCase):
|
|||||||
self.assertTrue(run['started'])
|
self.assertTrue(run['started'])
|
||||||
if NATIVE == 'x86_64':
|
if NATIVE == 'x86_64':
|
||||||
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
|
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
|
||||||
20, 'the x86-64 test program to exit')
|
30, 'the x86-64 test program to exit')
|
||||||
self.assertEqual(done['exit'], 0)
|
self.assertEqual(done['exit'], 0)
|
||||||
|
|
||||||
def test_reinstall_with_another_runtime_keeps_one_shortcut(self):
|
def test_reinstall_with_another_runtime_keeps_one_shortcut(self):
|
||||||
|
|||||||
@@ -220,9 +220,8 @@ def cmd_run_game(q):
|
|||||||
# _v2-entry-point prefix, even though Steam recorded the mapping
|
# _v2-entry-point prefix, even though Steam recorded the mapping
|
||||||
# (docs/sideloading.md, 2026-09-26). So does the fake, for real.
|
# (docs/sideloading.md, 2026-09-26). So does the fake, for real.
|
||||||
record.update(started=True, command=full)
|
record.update(started=True, command=full)
|
||||||
record.update(execute(full, game['directory'], {**game.get('env', {})}, f"devkit-{gameid}"))
|
record['run'] = (full, game['directory'], {**game.get('env', {})}, f'devkit-{gameid}')
|
||||||
with fs.update() as state:
|
add_launch(record)
|
||||||
state['launches'].append(record)
|
|
||||||
console(record['message'] if not record['started'] else f'devkit run-game: started devkit game "{gameid}"')
|
console(record['message'] if not record['started'] else f'devkit run-game: started devkit game "{gameid}"')
|
||||||
respond(q['response'], text='OK\n') # guess: steam-devkit-rpc only checks that it arrives
|
respond(q['response'], text='OK\n') # guess: steam-devkit-rpc only checks that it arrives
|
||||||
|
|
||||||
@@ -232,26 +231,31 @@ DEVKIT = {'approve-ssh-key': cmd_approve_ssh_key, 'create-shortcut': cmd_create_
|
|||||||
'run-game': cmd_run_game}
|
'run-game': cmd_run_game}
|
||||||
|
|
||||||
|
|
||||||
def execute(path, cwd, env, label):
|
def add_launch(record):
|
||||||
"""Start a program the way Steam would, and note its pid and exit status."""
|
"""Log a launch in the state. record['run'] = (path, cwd, env, log name) also starts the
|
||||||
os.makedirs(GAME_LOGS, exist_ok=True)
|
program the way Steam would, and notes its pid and, once it ends, its exit status."""
|
||||||
log = open(f'{GAME_LOGS}/{label}.log', 'ab')
|
run, proc = record.pop('run', None), None
|
||||||
try:
|
if run:
|
||||||
proc = subprocess.Popen([path], cwd=cwd if os.path.isdir(cwd) else HOME, env={**os.environ, **env},
|
path, cwd, env, label = run
|
||||||
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
|
os.makedirs(GAME_LOGS, exist_ok=True)
|
||||||
except OSError as e:
|
with open(f'{GAME_LOGS}/{label}.log', 'ab') as log:
|
||||||
return {'pid': None, 'exec_error': str(e)}
|
try:
|
||||||
finally:
|
proc = subprocess.Popen([path], cwd=cwd if os.path.isdir(cwd) else HOME, env={**os.environ, **env},
|
||||||
log.close()
|
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
|
||||||
|
record['pid'] = proc.pid
|
||||||
def reap():
|
except OSError as e:
|
||||||
code = proc.wait()
|
record.update(pid=None, exec_error=str(e))
|
||||||
with fs.update() as state:
|
with fs.update() as state: # before the reaper looks for it, however fast the program is
|
||||||
for r in state['launches']:
|
record['n'] = len(state['launches'])
|
||||||
if r.get('pid') == proc.pid and 'exit' not in r:
|
state['launches'].append(record)
|
||||||
r['exit'] = code
|
if proc:
|
||||||
threading.Thread(target=reap, daemon=True).start()
|
def reap():
|
||||||
return {'pid': proc.pid}
|
code = proc.wait()
|
||||||
|
with fs.update() as state:
|
||||||
|
mine = state['launches'][record['n']:record['n'] + 1]
|
||||||
|
if mine and mine[0].get('pid') == proc.pid: # not a reset's fresh list
|
||||||
|
mine[0]['exit'] = code
|
||||||
|
threading.Thread(target=reap, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
# ---- steam:// URLs from the `steam` wrapper ----------------------------------
|
# ---- steam:// URLs from the `steam` wrapper ----------------------------------
|
||||||
@@ -271,15 +275,14 @@ def url_rungameid(gid):
|
|||||||
# STEAM_COMPAT_SHADER_PATH (docs/apks.md, 2026-09-25).
|
# STEAM_COMPAT_SHADER_PATH (docs/apks.md, 2026-09-25).
|
||||||
env = {'SteamAppId': str(appid), 'SteamGameId': str(gid),
|
env = {'SteamAppId': str(appid), 'SteamGameId': str(gid),
|
||||||
'STEAM_FOSSILIZE_DUMP_PATH': f'{fs.STEAM_ROOT}/steamapps/shadercache/{appid}/fozpipelinesv6'}
|
'STEAM_FOSSILIZE_DUMP_PATH': f'{fs.STEAM_ROOT}/steamapps/shadercache/{appid}/fozpipelinesv6'}
|
||||||
record.update(appid=appid, started=True, command=sc['exe'])
|
record.update(appid=appid, started=True, command=sc['exe'],
|
||||||
record.update(execute(sc['exe'], sc.get('start_dir') or HOME, env, f'shortcut-{appid}'))
|
run=(sc['exe'], sc.get('start_dir') or HOME, env, f'shortcut-{appid}'))
|
||||||
else:
|
else:
|
||||||
with fs.update() as state:
|
with fs.update() as state:
|
||||||
app = next((a for a in state['steam']['apps'] if a['appid'] == gid), None)
|
app = next((a for a in state['steam']['apps'] if a['appid'] == gid), None)
|
||||||
record.update(appid=gid, started=bool(app and app['installed']),
|
record.update(appid=gid, started=bool(app and app['installed']),
|
||||||
message=None if app and app['installed'] else 'not installed')
|
message=None if app and app['installed'] else 'not installed')
|
||||||
with fs.update() as state:
|
add_launch(record)
|
||||||
state['launches'].append(record)
|
|
||||||
|
|
||||||
|
|
||||||
def url_install(appid):
|
def url_install(appid):
|
||||||
|
|||||||
+113
-28
@@ -13,10 +13,14 @@ BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
|
|||||||
(someone has to open Settings > Developer > Pair new host and approve it in
|
(someone has to open Settings > Developer > Pair new host and approve it in
|
||||||
the headset), checked, and taken out of authorized_keys again.
|
the headset), checked, and taken out of authorized_keys again.
|
||||||
|
|
||||||
Everything it installs is removed again, also when a step fails. Titles are
|
Everything it installs is removed again, also when a step fails: the titles
|
||||||
named fc_smoke_*; leftovers from an interrupted run are removed first.
|
(named fc_smoke_*, and leftovers of an interrupted run first), their Steam
|
||||||
|
shortcuts, the paired key, and ~/devkit-utils if it wasn't there before (if it
|
||||||
|
was, it stays, synced to this checkout as Frame Control always does). A
|
||||||
|
cleanup that fails is a failed step.
|
||||||
|
|
||||||
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
|
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
|
||||||
|
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
|
||||||
Exit status: 0 all passed, 1 a step failed, 2 the headset isn't reachable.
|
Exit status: 0 all passed, 1 a step failed, 2 the headset isn't reachable.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
@@ -42,7 +46,7 @@ import frame_connect # noqa: E402
|
|||||||
import frame_titles # noqa: E402
|
import frame_titles # noqa: E402
|
||||||
import tiny_programs # noqa: E402
|
import tiny_programs # noqa: E402
|
||||||
|
|
||||||
RESULTS = HERE / 'results'
|
RESULTS = Path(os.environ.get('FRAME_SMOKE_RESULTS') or HERE / 'results')
|
||||||
PREFIX = 'fc_smoke_'
|
PREFIX = 'fc_smoke_'
|
||||||
# Earlier runs named titles fc-smoke*, which Steam refused to register but left on disk.
|
# Earlier runs named titles fc-smoke*, which Steam refused to register but left on disk.
|
||||||
OLD_PREFIX = 'fc-smoke'
|
OLD_PREFIX = 'fc-smoke'
|
||||||
@@ -110,12 +114,19 @@ def status(build):
|
|||||||
return {k: s.get(k) for k in ('os', 'battery', 'power', 'temp', 'services', 'volume')}
|
return {k: s.get(k) for k in ('os', 'battery', 'power', 'temp', 'services', 'volume')}
|
||||||
|
|
||||||
|
|
||||||
def steam_log_lines(gid):
|
def steam_log_lines(*patterns):
|
||||||
"""What Steam logged about a title (log folder layout not verified; best effort)."""
|
"""Steam log lines holding any of the fixed strings (which files: not verified, so all)."""
|
||||||
out = ssh(f"grep -rsh -- {shlex.quote(gid)} ~/.local/share/Steam/logs/ 2>/dev/null | tail -n 12 || true")
|
pats = ' '.join(f'-e {shlex.quote(p)}' for p in patterns)
|
||||||
|
out = ssh(f"grep -rshF {pats} ~/.local/share/Steam/logs/ 2>/dev/null || true")
|
||||||
return out.strip().splitlines()
|
return out.strip().splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
# What a launch must show, per kind. The x86-64 runtime isn't installed on the
|
||||||
|
# Frame, so Steam acknowledges that launch and logs "... is not installed"
|
||||||
|
# instead (docs/sideloading.md, 2026-09-26): recorded, not a failure.
|
||||||
|
EXPECTED = {'arm64': ('running',), 'x86_64': ('running', 'runtime missing'), 'exe': ('running', 'started')}
|
||||||
|
|
||||||
|
|
||||||
def title_cycle(smoke, kind, folder):
|
def title_cycle(smoke, kind, folder):
|
||||||
path = tiny_programs.write(folder, kind)
|
path = tiny_programs.write(folder, kind)
|
||||||
gid = PREFIX + kind # named outright: the file names would share one id
|
gid = PREFIX + kind # named outright: the file names would share one id
|
||||||
@@ -129,10 +140,32 @@ def title_cycle(smoke, kind, folder):
|
|||||||
return {'id': gid, 'runtime': meta['runtime'], 'steam': meta.get('steam')}
|
return {'id': gid, 'runtime': meta['runtime'], 'steam': meta.get('steam')}
|
||||||
|
|
||||||
def launch():
|
def launch():
|
||||||
|
mine = (f'devkit-game/{gid}', f'"{gid}"')
|
||||||
|
# The missing-runtime line names Steam's app id, which we don't know, so
|
||||||
|
# any new one counts; nothing else is launching during the test.
|
||||||
|
seen, seen_missing = len(steam_log_lines(*mine)), len(steam_log_lines('but is not installed'))
|
||||||
frame_titles.launch(gid) # raises unless Steam confirmed it
|
frame_titles.launch(gid) # raises unless Steam confirmed it
|
||||||
time.sleep(4)
|
outcome, procs, new = 'no evidence', [], []
|
||||||
procs = ssh(f"pgrep -af -- {shlex.quote('devkit-game/' + gid)} || true").strip()
|
deadline = time.monotonic() + 12
|
||||||
return {'processes': procs.splitlines(), 'steam_log': steam_log_lines(gid)}
|
while time.monotonic() < deadline:
|
||||||
|
# [d]: the pattern mustn't match the shell running pgrep, whose command line holds it.
|
||||||
|
procs = ssh(f"pgrep -af -- '[d]evkit-game/{gid}/' || true").strip().splitlines()
|
||||||
|
new = steam_log_lines(*mine)[seen:]
|
||||||
|
missing = steam_log_lines('but is not installed')[seen_missing:]
|
||||||
|
if procs:
|
||||||
|
outcome = 'running'
|
||||||
|
elif missing:
|
||||||
|
outcome, new = 'runtime missing', new + missing
|
||||||
|
elif any('started devkit game' in line or 'chdir' in line for line in new):
|
||||||
|
outcome = 'started'
|
||||||
|
if outcome != 'no evidence':
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
detail = {'outcome': outcome, 'processes': procs, 'steam_log': new[-12:]}
|
||||||
|
if outcome not in EXPECTED[kind]:
|
||||||
|
raise AssertionError(f"Steam acknowledged the launch, but {outcome} (expected "
|
||||||
|
f"{' or '.join(EXPECTED[kind])}): {json.dumps(detail)[:400]}")
|
||||||
|
return detail
|
||||||
|
|
||||||
def remove():
|
def remove():
|
||||||
frame_titles.remove(gid)
|
frame_titles.remove(gid)
|
||||||
@@ -150,17 +183,60 @@ def title_cycle(smoke, kind, folder):
|
|||||||
|
|
||||||
|
|
||||||
def cleanup(smoke):
|
def cleanup(smoke):
|
||||||
"""Remove fc-smoke-* titles: this run's, and any an interrupted run left."""
|
"""Remove this run's titles and any fc_smoke_* an interrupted run left: the folder,
|
||||||
|
its json files, and (through steamos-delete) Steam's shortcut. Raises if anything stays."""
|
||||||
|
problems = []
|
||||||
try:
|
try:
|
||||||
ids = {t['id'] for t in frame_titles.list_titles() if t['id'].startswith((PREFIX, OLD_PREFIX))}
|
ids = {t['id'] for t in frame_titles.list_titles() if t['id'].startswith((PREFIX, OLD_PREFIX))}
|
||||||
except frame_android.FrameError:
|
except frame_android.FrameError as e:
|
||||||
ids = set(smoke.installed) # can't list: try what this run installed
|
ids = set()
|
||||||
|
problems.append(f'could not list titles: {e}')
|
||||||
|
ids |= smoke.installed
|
||||||
for gid in sorted(ids):
|
for gid in sorted(ids):
|
||||||
try:
|
try:
|
||||||
frame_titles.remove(gid)
|
if ssh(f'test -d ~/devkit-game/{gid} && echo yes || true').strip() == 'yes':
|
||||||
|
frame_titles.remove(gid)
|
||||||
|
# Also when remove() stopped part-way, or only the json files are left.
|
||||||
|
ssh(f"rm -f {' '.join(f'~/devkit-game/{gid}-{k}.json' for k in ('argv', 'env', 'settings', 'framecontrol'))}")
|
||||||
|
if ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip():
|
||||||
|
problems.append(f'{gid} is still on the Frame')
|
||||||
|
else:
|
||||||
|
smoke.installed.discard(gid)
|
||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
print(f' could not remove {gid}: {e}')
|
problems.append(f'{gid}: {e}')
|
||||||
return sorted(ids)
|
if ids:
|
||||||
|
# steamos-delete with no title only syncs Steam's shortcuts with ~/devkit-game.
|
||||||
|
try:
|
||||||
|
ssh('python3 ~/devkit-utils/steamos-delete 2>&1 || true', timeout=120)
|
||||||
|
except frame_android.FrameError as e:
|
||||||
|
problems.append(f'syncing Steam shortcuts: {e}')
|
||||||
|
if problems:
|
||||||
|
raise AssertionError('; '.join(problems))
|
||||||
|
return {'removed': sorted(ids)}
|
||||||
|
|
||||||
|
|
||||||
|
# Runs on the Frame: drop the lines holding one key from authorized_keys, writing a
|
||||||
|
# copy with the same mode and swapping it in, so a failure can't truncate the file.
|
||||||
|
DROP_KEY = r"""
|
||||||
|
import os, sys, tempfile
|
||||||
|
path = os.path.expanduser('~/.ssh/authorized_keys')
|
||||||
|
with open(path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
keep = [line for line in lines if sys.argv[1] not in line]
|
||||||
|
if len(keep) < len(lines):
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix='.authorized_keys.')
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w') as f:
|
||||||
|
f.writelines(keep)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.chmod(tmp, os.stat(path).st_mode & 0o777)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise
|
||||||
|
print(len(lines) - len(keep))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def pair(folder):
|
def pair(folder):
|
||||||
@@ -169,8 +245,8 @@ def pair(folder):
|
|||||||
subprocess.run(['ssh-keygen', '-q', '-t', 'rsa', '-b', '3072', '-N', '', '-C', 'frame-control-smoke',
|
subprocess.run(['ssh-keygen', '-q', '-t', 'rsa', '-b', '3072', '-N', '', '-C', 'frame-control-smoke',
|
||||||
'-f', key], check=True)
|
'-f', key], check=True)
|
||||||
pub = Path(key + '.pub').read_text()
|
pub = Path(key + '.pub').read_text()
|
||||||
host = ssh_config('hostname') or ALIAS
|
host, user, port = ssh_config('hostname') or ALIAS, ssh_config('user'), ssh_config('port') or '22'
|
||||||
user = ssh_config('user')
|
known = ssh_config('userknownhostsfile') or '~/.ssh/known_hosts'
|
||||||
comment = frame_connect.key_comment(platform.node()).replace('frame-control@', 'frame-control-smoke@')
|
comment = frame_connect.key_comment(platform.node()).replace('frame-control@', 'frame-control-smoke@')
|
||||||
print(' In the headset: Steam Settings > Developer > Pair new host, then approve '
|
print(' In the headset: Steam Settings > Developer > Pair new host, then approve '
|
||||||
f'"{comment}" (waits up to {frame_connect.PAIRING_MODE_WAIT} s)', flush=True)
|
f'"{comment}" (waits up to {frame_connect.PAIRING_MODE_WAIT} s)', flush=True)
|
||||||
@@ -179,16 +255,21 @@ def pair(folder):
|
|||||||
reason = frame_connect.devkit_pair(host, pub, comment)
|
reason = frame_connect.devkit_pair(host, pub, comment)
|
||||||
if reason:
|
if reason:
|
||||||
raise AssertionError(reason)
|
raise AssertionError(reason)
|
||||||
r = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', '-o', 'ConnectTimeout=8',
|
# Only this key: no ssh_config (whose IdentityFile lines IdentitiesOnly would
|
||||||
'-o', 'ControlPath=none', '-i', key, f'{user}@{host}', 'true'], capture_output=True, text=True)
|
# still offer), no agent, no passwords.
|
||||||
|
r = subprocess.run(['ssh', '-F', '/dev/null', '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes',
|
||||||
|
'-o', 'IdentityAgent=none', '-o', 'PasswordAuthentication=no',
|
||||||
|
'-o', 'KbdInteractiveAuthentication=no', '-o', 'ConnectTimeout=8',
|
||||||
|
'-o', 'StrictHostKeyChecking=yes', '-o', f'UserKnownHostsFile={known}',
|
||||||
|
'-p', port, '-i', key, f'{user}@{host}', 'true'], capture_output=True, text=True)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
raise AssertionError(f'paired, but the key does not log in: {r.stderr.strip()}')
|
raise AssertionError(f'paired, but the key does not log in: {r.stderr.strip()}')
|
||||||
return {'comment': comment}
|
return {'comment': comment}
|
||||||
finally:
|
finally:
|
||||||
f = '~/.ssh/authorized_keys'
|
dropped = frame_android.ssh(f'python3 - {shlex.quote(b64)}', input=DROP_KEY).strip()
|
||||||
ssh(f'grep -vF {b64} {f} > {f}.smoke; cat {f}.smoke > {f}; rm -f {f}.smoke')
|
if b64 in ssh('cat ~/.ssh/authorized_keys'):
|
||||||
if b64 in ssh(f'cat {f}'):
|
|
||||||
raise AssertionError('the smoke key is still in authorized_keys')
|
raise AssertionError('the smoke key is still in authorized_keys')
|
||||||
|
print(f' removed the smoke key from authorized_keys ({dropped} line(s))', flush=True)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -207,9 +288,10 @@ def main():
|
|||||||
release = os_release()
|
release = os_release()
|
||||||
build = release['BUILD_ID'] or 'unknown'
|
build = release['BUILD_ID'] or 'unknown'
|
||||||
print(f'==> Frame smoke test on {ALIAS}: SteamOS {release["VERSION_ID"]} ({release["VARIANT_ID"]}), build {build}')
|
print(f'==> Frame smoke test on {ALIAS}: SteamOS {release["VERSION_ID"]} ({release["VARIANT_ID"]}), build {build}')
|
||||||
left = cleanup(smoke)
|
# Frame Control copies Valve's devkit tools to ~/devkit-utils on the first install
|
||||||
if left:
|
# (as Valve's client does). If they weren't there before, they go again at the end.
|
||||||
print(f' removed leftovers from an earlier run: {", ".join(left)}')
|
had_utils = ssh('test -d ~/devkit-utils && echo yes || true').strip() == 'yes'
|
||||||
|
smoke.step('cleanup: leftovers from earlier runs', cleanup, smoke)
|
||||||
folder = tempfile.mkdtemp(prefix='frame-smoke-')
|
folder = tempfile.mkdtemp(prefix='frame-smoke-')
|
||||||
try:
|
try:
|
||||||
smoke.step('devkit service: properties.json', properties)
|
smoke.step('devkit service: properties.json', properties)
|
||||||
@@ -219,15 +301,18 @@ def main():
|
|||||||
if args.pair:
|
if args.pair:
|
||||||
smoke.step('devkit pairing (throwaway key)', pair, folder)
|
smoke.step('devkit pairing (throwaway key)', pair, folder)
|
||||||
finally:
|
finally:
|
||||||
removed = cleanup(smoke)
|
smoke.step('cleanup: everything this run installed', cleanup, smoke)
|
||||||
|
if not had_utils:
|
||||||
|
smoke.step('cleanup: ~/devkit-utils (not there before)', ssh,
|
||||||
|
'rm -rf ~/devkit-utils ~/.devkit-utils.frame-control && echo removed')
|
||||||
subprocess.run(['rm', '-rf', folder])
|
subprocess.run(['rm', '-rf', folder])
|
||||||
passed = sum(s['ok'] for s in smoke.steps)
|
passed = sum(s['ok'] for s in smoke.steps)
|
||||||
RESULTS.mkdir(exist_ok=True)
|
RESULTS.mkdir(exist_ok=True)
|
||||||
out = RESULTS / f"{started.replace(':', '')}-{build}.json"
|
out = RESULTS / f"{started.replace(':', '')}-{build}.json"
|
||||||
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'os_release': release, 'paired': args.pair,
|
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'os_release': release, 'paired': args.pair,
|
||||||
'passed': passed, 'failed': len(smoke.steps) - passed,
|
'passed': passed, 'failed': len(smoke.steps) - passed,
|
||||||
'cleanup_removed': removed, 'steps': smoke.steps}, indent=1))
|
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
|
||||||
print(f'==> {passed}/{len(smoke.steps)} steps passed on build {build}; results in {out.relative_to(ROOT)}')
|
print(f'==> {passed}/{len(smoke.steps)} steps passed on build {build}; results in {out}')
|
||||||
return 0 if passed == len(smoke.steps) else 1
|
return 0 if passed == len(smoke.steps) else 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ smoke test and the fake Frame's e2e tests install. Python stdlib only.
|
|||||||
"""
|
"""
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
SLEEP_SECONDS = 5
|
SLEEP_SECONDS = 10
|
||||||
BASE = 0x400000
|
BASE = 0x400000
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user