From f1aaed6279e5403f50566e206c671fd82592e70d Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:40:01 +1100 Subject: [PATCH 1/3] Tests: keep every test (and the servers they start) off the real ~/.ssh Servers started by test_server's OneServer and LocalMode tests got no FRAME_CONTROL_SSH_DIR, so each one imported the real ~/.ssh/config into a fresh throwaway devices.json and seeded a pin into the real ~/.ssh/frame-control-hosts: about a dozen files per suite run. tests/sandbox.py now gives the whole run a throwaway HOME/USERPROFILE and FRAME_CONTROL_SSH_DIR (inherited by child processes), every test module imports it first, and frame_connect.SSH_DIR honours FRAME_CONTROL_SSH_DIR like frame_devices.ssh_dir(). test_sandbox checks the paths and reruns the tests that leaked from the original environment, asserting nothing under the real ~/.ssh changed. Co-Authored-By: Claude Opus 5.5 --- tests/sandbox.py | 21 +++++++- tests/test_agent.py | 1 + tests/test_apk_artwork.py | 1 + tests/test_apk_more_sources.py | 1 + tests/test_apk_search.py | 1 + tests/test_comfort.py | 1 + tests/test_comfort_ui.py | 1 + tests/test_fdroid_sources.py | 1 + tests/test_frame_android_data.py | 1 + tests/test_frame_android_library.py | 1 + tests/test_frame_apk_vr.py | 1 + tests/test_input.py | 1 + tests/test_library_entrypoints.py | 1 + tests/test_macview.py | 1 + tests/test_media.py | 1 + tests/test_panels.py | 1 + tests/test_sandbox.py | 79 +++++++++++++++++++++++++++++ tests/test_sidequest.py | 1 + tests/test_touch.py | 1 + tests/test_vr.py | 1 + ui/frame_connect.py | 3 +- 21 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 tests/test_sandbox.py diff --git a/tests/sandbox.py b/tests/sandbox.py index 5d2a3f0..ba01593 100644 --- a/tests/sandbox.py +++ b/tests/sandbox.py @@ -1,12 +1,20 @@ """Imported first by every test module: nothing a test does reaches this person's -app data, their telemetry, or the shared compatibility database. +app data, their ~/.ssh, their telemetry, or the shared compatibility database. Must run before any ui module is imported, since those read these at import time. +The variables are set in os.environ, so servers and scripts the tests start +inherit them too (they pass {**os.environ, ...}). """ import atexit import os import shutil import tempfile +from pathlib import Path + +# The environment and real ~/.ssh from before the changes below: test_sandbox +# reruns tests from that environment and checks ~/.ssh is left alone. +ORIGINAL_ENV = dict(os.environ) +REAL_SSH_DIR = Path.home() / ".ssh" _dir = tempfile.mkdtemp(prefix="frame-control-tests-") atexit.register(shutil.rmtree, _dir, ignore_errors=True) @@ -14,3 +22,14 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir os.environ["FRAME_CONTROL_TELEMETRY"] = "0" # A maintainer's machine holds the database key; send anything that slips through nowhere. os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9" + +# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins, +# its lock file) and the other per-user folders some modules use. A server started +# without FRAME_CONTROL_SSH_DIR used to import the real ~/.ssh/config and seed a pin +# into the real ~/.ssh/frame-control-hosts on every run. +HOME = os.path.join(_dir, "home") +SSH_DIR = os.path.join(HOME, ".ssh") +os.makedirs(SSH_DIR, mode=0o700) +os.environ["HOME"] = HOME +os.environ["USERPROFILE"] = HOME # Path.home() on Windows +os.environ["FRAME_CONTROL_SSH_DIR"] = SSH_DIR # frame_devices.ssh_dir(), frame_connect.SSH_DIR diff --git a/tests/test_agent.py b/tests/test_agent.py index 931eccb..83d0b2c 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1,4 +1,5 @@ """MCP protocol, exact-action approvals and explicit assistant data sharing.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import io import json import os diff --git a/tests/test_apk_artwork.py b/tests/test_apk_artwork.py index b036f90..e863afe 100644 --- a/tests/test_apk_artwork.py +++ b/tests/test_apk_artwork.py @@ -1,3 +1,4 @@ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import http.client import json from pathlib import Path diff --git a/tests/test_apk_more_sources.py b/tests/test_apk_more_sources.py index f1879b9..f1f3cf7 100644 --- a/tests/test_apk_more_sources.py +++ b/tests/test_apk_more_sources.py @@ -1,3 +1,4 @@ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import hashlib, io, json, os, sys, tempfile, time, unittest, urllib.error, urllib.request, zipfile from pathlib import Path from unittest.mock import patch diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py index 20cd57e..6b4e3ed 100644 --- a/tests/test_apk_search.py +++ b/tests/test_apk_search.py @@ -1,3 +1,4 @@ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import http.client import json from pathlib import Path diff --git a/tests/test_comfort.py b/tests/test_comfort.py index 0b3af36..43a456f 100644 --- a/tests/test_comfort.py +++ b/tests/test_comfort.py @@ -1,4 +1,5 @@ """Fake-Frame session clock/actions plus real helper serialization and sensor probes.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import os import shutil import json diff --git a/tests/test_comfort_ui.py b/tests/test_comfort_ui.py index 2d75b36..ef008d6 100644 --- a/tests/test_comfort_ui.py +++ b/tests/test_comfort_ui.py @@ -1,4 +1,5 @@ """Run the actual shared page's comfort renderer against a minimal DOM/bridge.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import pathlib import shutil import subprocess diff --git a/tests/test_fdroid_sources.py b/tests/test_fdroid_sources.py index f264482..6ec0b2b 100644 --- a/tests/test_fdroid_sources.py +++ b/tests/test_fdroid_sources.py @@ -1,4 +1,5 @@ """Offline authenticated repository fixtures; no tests contact a server.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import io import json import os diff --git a/tests/test_frame_android_data.py b/tests/test_frame_android_data.py index 6b9d3e8..251e04b 100644 --- a/tests/test_frame_android_data.py +++ b/tests/test_frame_android_data.py @@ -1,3 +1,4 @@ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import io import json import os diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index 3831254..08cd4eb 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -1,4 +1,5 @@ """Offline artwork, Steam API and launcher supervision regressions.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import importlib.util import json import os diff --git a/tests/test_frame_apk_vr.py b/tests/test_frame_apk_vr.py index f99d711..efc5d0f 100644 --- a/tests/test_frame_apk_vr.py +++ b/tests/test_frame_apk_vr.py @@ -1,4 +1,5 @@ """Local-only VR manifest, raw ZIP and independent signature checks.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import io import os from pathlib import Path diff --git a/tests/test_input.py b/tests/test_input.py index 3e13302..0ab45d3 100644 --- a/tests/test_input.py +++ b/tests/test_input.py @@ -6,6 +6,7 @@ identity line, then starts TLS as the *client*. Run: python3 -m unittest discover -s tests """ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import json import os import shutil diff --git a/tests/test_library_entrypoints.py b/tests/test_library_entrypoints.py index 2796353..63fcbb3 100644 --- a/tests/test_library_entrypoints.py +++ b/tests/test_library_entrypoints.py @@ -1,4 +1,5 @@ """Every public sideload entry point reaches the mandatory five-slot writer.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import contextlib import io import json diff --git a/tests/test_macview.py b/tests/test_macview.py index e636927..16e0de5 100644 --- a/tests/test_macview.py +++ b/tests/test_macview.py @@ -7,6 +7,7 @@ input reaching the source, and closing. Run: python3 -m unittest discover -s tests """ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import base64 import http.client import json diff --git a/tests/test_media.py b/tests/test_media.py index f277341..99c2931 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -1,4 +1,5 @@ """Owned media planning, eye isolation, decoder choice and fake-Frame ownership.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import argparse import io import json diff --git a/tests/test_panels.py b/tests/test_panels.py index 6cfb241..650bd4b 100644 --- a/tests/test_panels.py +++ b/tests/test_panels.py @@ -1,4 +1,5 @@ """Fake-Frame panel responses and the headset page's real HTTP guards.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import http.client import json from pathlib import Path diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 0000000..0c719a4 --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,79 @@ +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) +import frame_connect # noqa: E402 +import frame_devices # noqa: E402 + + +def inside(path, folder): + path, folder = os.path.realpath(path), os.path.realpath(folder) + return path == folder or path.startswith(folder + os.sep) + + +def snapshot(folder): + """{relative path: (size, mtime)} for everything under FOLDER.""" + out = {} + for base, dirs, files in os.walk(folder): + for name in dirs + files: + p = os.path.join(base, name) + try: + st = os.lstat(p) + except OSError: + continue + out[os.path.relpath(p, folder)] = (st.st_size, st.st_mtime_ns) + return out + + +class Sandbox(unittest.TestCase): + """Tests once wrote a host-key pin into the real ~/.ssh/frame-control-hosts on every + run (a server started without FRAME_CONTROL_SSH_DIR imported the real ~/.ssh/config).""" + + def test_home_and_ssh_dir_are_throwaway(self): + tmp = tempfile.gettempdir() + self.assertTrue(inside(Path.home(), tmp), Path.home()) + self.assertTrue(inside(os.path.expanduser("~"), tmp)) + for d in (frame_devices.ssh_dir(), frame_devices.pin_dir(), frame_connect.SSH_DIR, + os.environ["FRAME_CONTROL_SSH_DIR"]): + self.assertTrue(inside(d, tmp), d) + self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d) + + def test_child_processes_inherit_it(self): + out = subprocess.run( + [sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_devices, frame_connect;" + " from pathlib import Path; print(Path.home()); print(frame_devices.ssh_dir()); print(frame_connect.SSH_DIR)", + str(ROOT / "ui")], env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + capture_output=True, text=True, check=True).stdout.split("\n") + for d in filter(None, out): + self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d) + self.assertTrue(inside(d, tempfile.gettempdir()), d) + + def test_real_ssh_folder_untouched_by_tests_that_start_servers(self): + """Reruns, from the environment the suite was started in, the tests that used to + leak (servers started without FRAME_CONTROL_SSH_DIR) plus the device and connect + tests, and checks nothing under the real ~/.ssh changed.""" + real = sandbox.REAL_SSH_DIR + if inside(real, tempfile.gettempdir()): + self.skipTest("this run's HOME is already a temporary folder") + before = snapshot(real) + env = {k: v for k, v in sandbox.ORIGINAL_ENV.items() if k != "FRAME_CONTROL_SSH_DIR"} + env["PYTHONDONTWRITEBYTECODE"] = "1" + r = subprocess.run([sys.executable, "-m", "unittest", "-q", "test_server.OneServer", "test_server.LocalMode", + "test_devices", "test_connect"], + cwd=str(Path(__file__).resolve().parent), env=env, capture_output=True, text=True, + timeout=300) + self.assertEqual(r.returncode, 0, r.stderr[-3000:]) + after = snapshot(real) + changed = sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k) + and not k.startswith("agent")) # ssh-agent sockets come and go on their own + self.assertEqual(changed, [], f"tests changed the real {real}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sidequest.py b/tests/test_sidequest.py index 11d8d19..37e948a 100644 --- a/tests/test_sidequest.py +++ b/tests/test_sidequest.py @@ -1,3 +1,4 @@ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import json from pathlib import Path import sys diff --git a/tests/test_touch.py b/tests/test_touch.py index b9ff731..5d0c7c6 100644 --- a/tests/test_touch.py +++ b/tests/test_touch.py @@ -2,6 +2,7 @@ Run: python3 -m unittest discover -s tests """ +import sandbox # noqa: F401 (first: keeps tests off real data and services) import io import json import os diff --git a/tests/test_vr.py b/tests/test_vr.py index e206173..13d25c0 100644 --- a/tests/test_vr.py +++ b/tests/test_vr.py @@ -1,4 +1,5 @@ """Own VR telemetry, optional catalogue and entitlement guards; no headset needed.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) import ctypes import json from pathlib import Path diff --git a/ui/frame_connect.py b/ui/frame_connect.py index 1b66a9b..37e50b2 100644 --- a/ui/frame_connect.py +++ b/ui/frame_connect.py @@ -31,7 +31,8 @@ import frame_host # noqa: E402 FRAME_USER = os.environ.get("FRAME_USER", "steamos") USER_FROM_ENV = "FRAME_USER" in os.environ FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame") -SSH_DIR = Path.home() / ".ssh" +# $FRAME_CONTROL_SSH_DIR in tests, as frame_devices.ssh_dir(), so they never touch the real one. +SSH_DIR = Path(os.environ.get("FRAME_CONTROL_SSH_DIR") or Path.home() / ".ssh") KEY = SSH_DIR / "id_ed25519_frame" # The devkit service only accepts ssh-rsa keys (write_key in Valve's # steamos-devkit-service), so pairing uses a second key next to the ed25519 one. From 226ec40720b9d747d0c3e04e1737f2d8c72e6a70 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:56:26 +1100 Subject: [PATCH 2/3] Tests: keep the e2e container's real home; sandbox the per-user cache folders too tests/e2e/test_android imported its APK helpers from the sandboxed test_frame_apk, so the HOME move reached the whole e2e process: frame_connect wrote keys and config into the temp home while OpenSSH, which finds ~/.ssh from the password database, kept reading the container user's. The helpers now live in the sandbox-free tests/apk_fixtures.py, and the e2e harness sets the data dir, telemetry and compat DB guards itself (it used to get them through that import). sandbox.py also points LOCALAPPDATA, APPDATA and the XDG folders into the throwaway home, so test servers on Windows and Linux no longer warm indexes or sweep staging folders in the real app cache. test_sandbox checks both. Co-Authored-By: Claude Opus 5.5 --- tests/apk_fixtures.py | 87 ++++++++++++++++++++++++++++++++++++++ tests/e2e/harness.py | 10 +++++ tests/e2e/test_android.py | 2 +- tests/sandbox.py | 11 +++++ tests/test_frame_apk.py | 77 +-------------------------------- tests/test_frame_apk_vr.py | 2 +- tests/test_sandbox.py | 24 ++++++++++- 7 files changed, 134 insertions(+), 79 deletions(-) create mode 100644 tests/apk_fixtures.py diff --git a/tests/apk_fixtures.py b/tests/apk_fixtures.py new file mode 100644 index 0000000..20cf7bf --- /dev/null +++ b/tests/apk_fixtures.py @@ -0,0 +1,87 @@ +"""Small APKs built in memory (binary manifest plus resource table), for test_frame_apk +and the end-to-end tests. Deliberately doesn't import sandbox: tests/e2e runs in a +throwaway container and keeps its real home, which sandbox would move.""" +import io +import os +import struct +import sys +import zipfile + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui')) +import frame_apk # noqa: E402 + + +def pool(strings, utf8=False): + """A ResStringPool chunk.""" + data, offsets = b'', [] + for s in strings: + offsets.append(len(data)) + if utf8: + b = s.encode() + data += bytes([len(s), len(b)]) + b + b'\0' + else: + data += struct.pack('. + + package_raw=False drops the package's raw string (as some repackers do); + foreign_label adds a non-android `label` attribute after android:label. + """ + strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk', + 'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id + resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c) + resmap = struct.pack('. - - package_raw=False drops the package's raw string (as some repackers do); - foreign_label adds a non-android `label` attribute after android:label. - """ - strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk', - 'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id - resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c) - resmap = struct.pack(' Date: Mon, 12 Oct 2026 16:21:09 +1100 Subject: [PATCH 3/3] e2e: run only inside scripts/e2e.sh's container, and never start a server otherwise test_agents had no harness.require() and FrameTestCase started ui/server.py unconditionally, so plain discovery over tests/e2e started a server on this computer's real app data, ~/.ssh and cache. FRAME_E2E=1 alone outside the container did the same with the real cache. require() now also needs FRAME_E2E_CONTAINER=1 (set in compose.yaml) and the container runtime's marker file, and says why it refuses; FrameTestCase and Server.start call it before starting anything; test_agents calls it at module level. test_sandbox checks discovery over tests/e2e, with and without FRAME_E2E=1, skips everything and starts no server. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/harness.py | 21 +++++++++++++++++---- tests/e2e/test_agents.py | 2 ++ tests/fakeframe/compose.yaml | 1 + tests/test_sandbox.py | 21 +++++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index 04e9e06..af7a295 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -23,7 +23,13 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')] -ENABLED = os.environ.get('FRAME_E2E') == '1' +# Only inside the compose `host` container (FRAME_E2E_CONTAINER from compose.yaml, plus +# the container runtime's marker file): these tests drive ui/server.py with the real +# home, ~/.ssh and cache, which is only safe in a throwaway container. +REQUESTED = os.environ.get('FRAME_E2E') == '1' +IN_CONTAINER = os.environ.get('FRAME_E2E_CONTAINER') == '1' and ( + os.path.exists('/.dockerenv') or os.path.exists('/run/.containerenv')) +ENABLED = REQUESTED and IN_CONTAINER if ENABLED: # What tests/sandbox.py does, minus moving HOME: the container's real ~/.ssh is what # points `ssh frame` at the fake Frame, and OpenSSH reads ~/.ssh from the password @@ -40,9 +46,14 @@ SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log') def require(): - """Call at module level: skips the module unless the fake Frame is up.""" - if not ENABLED: - raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)') + """Skips (the module, at module level; the class, in setUpClass) unless this is + scripts/e2e.sh's container with the fake Frame up.""" + if ENABLED: + return + if REQUESTED: + raise unittest.SkipTest('FRAME_E2E=1 outside the e2e container: these tests use the real home, ~/.ssh ' + 'and app cache, so they only run in scripts/e2e.sh\'s throwaway container') + raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)') # ---- the fake Frame's control port -------------------------------------------- @@ -107,6 +118,7 @@ class Server: @classmethod def start(cls): + require() # never a server on this computer's real data if cls.proc and cls.proc.poll() is None: return with socket.socket() as s: @@ -203,6 +215,7 @@ class FrameTestCase(unittest.TestCase): @classmethod def setUpClass(cls): + require() Server.start() def setUp(self): diff --git a/tests/e2e/test_agents.py b/tests/e2e/test_agents.py index f02f338..10aa218 100644 --- a/tests/e2e/test_agents.py +++ b/tests/e2e/test_agents.py @@ -6,6 +6,8 @@ import sys import harness from harness import api, ok, finished, ssh +harness.require() + sys.path.insert(0, str(harness.ROOT / 'ui')) import frame_mcp diff --git a/tests/fakeframe/compose.yaml b/tests/fakeframe/compose.yaml index 089321c..d0e492b 100644 --- a/tests/fakeframe/compose.yaml +++ b/tests/fakeframe/compose.yaml @@ -43,6 +43,7 @@ services: FAKEFRAME_CTL: http://fakeframe:9999 FAKEFRAME_HOST: fakeframe FRAME_E2E: "1" + FRAME_E2E_CONTAINER: "1" # tests/e2e/harness.py refuses to run anywhere else healthcheck: test: ["CMD", "test", "-f", "/home/tester/.ready"] interval: 1s diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index c60f9c3..6a1ab81 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -66,6 +66,27 @@ class Sandbox(unittest.TestCase): for f in [*(here / "e2e").glob("*.py"), *(here / "smoke").glob("*.py"), here / "apk_fixtures.py"]: self.assertIsNone(pattern.search(f.read_text(encoding="utf-8")), f) + def test_end_to_end_tests_start_no_server_outside_their_container(self): + """Plain discovery over tests/e2e, with or without FRAME_E2E=1, must skip everything + and never start ui/server.py: outside scripts/e2e.sh's container it would run on + this computer's real app data, ~/.ssh and cache.""" + e2e = str(Path(__file__).resolve().parent / "e2e") + code = ("import io, sys, unittest; sys.path.insert(0, sys.argv[1]); import harness; calls = []\n" + "harness.Server.start = classmethod(lambda cls: calls.append(cls))\n" + "suite = unittest.defaultTestLoader.discover(sys.argv[1], top_level_dir=sys.argv[1])\n" + "r = unittest.TextTestRunner(stream=io.StringIO()).run(suite)\n" + "print(len(calls), r.testsRun, len(r.skipped), len(r.errors) + len(r.failures))") + for extra in ({}, {"FRAME_E2E": "1"}): + env = {k: v for k, v in os.environ.items() if not k.startswith("FRAME_E2E")} + env.update(extra, PYTHONDONTWRITEBYTECODE="1") + out = subprocess.run([sys.executable, "-c", code, e2e], env=env, capture_output=True, text=True, + timeout=120) + self.assertEqual(out.returncode, 0, out.stderr[-2000:]) + started, ran, skipped, broken = map(int, out.stdout.split()) + self.assertEqual((started, broken), (0, 0), (extra, out.stdout)) + self.assertEqual(ran, skipped, (extra, out.stdout)) + self.assertGreater(skipped, 0, extra) + def test_child_processes_inherit_it(self): out = subprocess.run( [sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_devices, frame_connect;"