From f74ae0d8620ec67e47ca99e5755656cc52ce3511 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 24 Sep 2026 04:35:14 -0400 Subject: [PATCH] test: capture PM desktop launches under isolated interpreter --- tests/install/README.md | 2 +- .../e2e-assets/launch-capture/pm-launch.py | 35 ++++++++++ .../launch-capture/sitecustomize.py | 7 +- tests/install/installer-script-e2e.sh | 17 +++-- .../install/test_pm_desktop_launch_capture.py | 68 +++++++++++++++++++ 5 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 tests/install/e2e-assets/launch-capture/pm-launch.py create mode 100644 tests/scripts/install/test_pm_desktop_launch_capture.py diff --git a/tests/install/README.md b/tests/install/README.md index 518b62efc3..af5f967ea8 100644 --- a/tests/install/README.md +++ b/tests/install/README.md @@ -147,7 +147,7 @@ A leg can install a release from months back. The driver must not assume that th The desktop app has two launch paths, so the matrix has two app-update methods. Both click "Update now" in the running app. They differ in how the app starts: - `open-app-update`: the app starts from the installed app entry point. On Windows, both the desktop installer and `installer-script+desktop` create shortcuts, so both support this route. On Linux and macOS, the script's opt-in desktop stage builds inside the checkout without registering an OS entry point. The macOS route therefore requires a desktop-installer install; Linux has no open-app-update leg. -- `hermes-desktop-app-update`: the app starts with the `hermes desktop` command. Every install method provides this command, on each OS that ships the desktop app. On linux this is the only app surface: no desktop installer and no packaged desktop artifact exist for linux. The driver captures the product's own launch call (argv, cwd, environment) with `e2e-assets/launch-capture/sitecustomize.py` and re-executes it under Playwright, which owns the app and clicks the update flow. +- `hermes-desktop-app-update`: the app starts with the `hermes desktop` command. Every install method provides this command, on each OS that ships the desktop app. On linux this is the only app surface: no desktop installer and no packaged desktop artifact exist for linux. The driver captures the product's own launch call (argv, cwd, environment) with `e2e-assets/launch-capture/sitecustomize.py` and re-executes it under Playwright, which owns the app and clicks the update flow. Pre-PM console scripts load the capture hook via `PYTHONPATH`; PM launchers use `-I`, so `launch-capture/pm-launch.py` obtains the installed launcher's own isolated runtime command and loads the driver hook before its bootstrap. ## Skips diff --git a/tests/install/e2e-assets/launch-capture/pm-launch.py b/tests/install/e2e-assets/launch-capture/pm-launch.py new file mode 100644 index 0000000000..14d04fd2cf --- /dev/null +++ b/tests/install/e2e-assets/launch-capture/pm-launch.py @@ -0,0 +1,35 @@ +"""Run an installed PM CLI with driver-side launch capture under its own -I interpreter. + +The published launcher exposes its installation-bound command. Inject only the +capture module ahead of that command's bootstrap; keep its selected interpreter, +isolated flag, bootstrap, and argv instead of constructing a new product launch. +""" + +import json +import os +from pathlib import Path +import subprocess +import sys + + +def main() -> int: + launcher = sys.argv[1] + spec = sys.argv[2] + query = subprocess.run( + [launcher, "--print-runtime-command", "--", "desktop"], + check=True, capture_output=True, text=True, + ) + command = json.loads(query.stdout) + if (not isinstance(command, list) or len(command) != 5 + or command[1:3] != ["-I", "-c"] or command[4] != "desktop" + or not all(isinstance(part, str) for part in command)): + raise ValueError("installed PM launcher did not provide an isolated desktop command") + capture = Path(__file__).with_name("sitecustomize.py") + command[3] = f"import runpy; runpy.run_path({str(capture)!r}); " + command[3] + env = os.environ.copy() + env["HERMES_E2E_CAPTURE_LAUNCH"] = spec + return subprocess.run(command, env=env).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/install/e2e-assets/launch-capture/sitecustomize.py b/tests/install/e2e-assets/launch-capture/sitecustomize.py index bf7b391169..f7c11cb57c 100644 --- a/tests/install/e2e-assets/launch-capture/sitecustomize.py +++ b/tests/install/e2e-assets/launch-capture/sitecustomize.py @@ -1,8 +1,9 @@ """Driver-side spawn interception for hermes desktop E2E legs. -The installed ``hermes`` is a venv console script, so its interpreter -imports ``sitecustomize`` at startup when this directory is on -``PYTHONPATH``. Behind an explicit env-var opt-in the module wraps +Pre-PM ``hermes`` is a venv console script that imports this module at +startup via ``PYTHONPATH``. PM launchers use -I and instead load it via +the driver-side pm-launch.py before the installed bootstrap. With an +explicit env-var opt-in the module wraps ``subprocess.run`` so the FINAL electron launch call of ``hermes desktop`` is captured -- argv, cwd, and the fully-constructed ``env`` kwarg written to a JSON spec -- and replaced with a fake success instead diff --git a/tests/install/installer-script-e2e.sh b/tests/install/installer-script-e2e.sh index ac2e81e4b1..efca303714 100755 --- a/tests/install/installer-script-e2e.sh +++ b/tests/install/installer-script-e2e.sh @@ -473,10 +473,19 @@ case "$UPDATE_METHOD" in step "capturing the hermes desktop launch spec (build runs for real)" rc=0 - (cd "$INSTALL_DIR" && \ - PYTHONPATH="$ASSETS/launch-capture${PYTHONPATH:+:$PYTHONPATH}" \ - HERMES_E2E_CAPTURE_LAUNCH="$SPEC" \ - source_build_env "$HERMES" desktop < /dev/null 2>&1 | ts_prefix > "$LOG_DIR/desktop-launch-capture.log") || rc=$? + if [ "$HERMES" = "$INSTALL_DIR/.hermes/bin/hermes" ]; then + # The PM launcher uses -I: PYTHONPATH/sitecustomize cannot reach it. + # Ask the installed launcher for its own isolated command, then inject + # the driver hook into that command without changing product code. + (cd "$INSTALL_DIR" && source_build_env python3 -I "$ASSETS/launch-capture/pm-launch.py" \ + "$HERMES" "$SPEC" < /dev/null 2>&1 | ts_prefix > "$LOG_DIR/desktop-launch-capture.log") || rc=$? + else + # Pre-PM console scripts load sitecustomize from PYTHONPATH. + (cd "$INSTALL_DIR" && \ + PYTHONPATH="$ASSETS/launch-capture${PYTHONPATH:+:$PYTHONPATH}" \ + HERMES_E2E_CAPTURE_LAUNCH="$SPEC" \ + source_build_env "$HERMES" desktop < /dev/null 2>&1 | ts_prefix > "$LOG_DIR/desktop-launch-capture.log") || rc=$? + fi log_group "hermes desktop (launch capture) transcript" "$LOG_DIR/desktop-launch-capture.log" [ "$rc" -eq 0 ] || fail "hermes desktop exited $rc during launch capture; transcript above" # Exit 0 without a capture means a version that never reached its diff --git a/tests/scripts/install/test_pm_desktop_launch_capture.py b/tests/scripts/install/test_pm_desktop_launch_capture.py new file mode 100644 index 0000000000..514ae37d3a --- /dev/null +++ b/tests/scripts/install/test_pm_desktop_launch_capture.py @@ -0,0 +1,68 @@ +"""The installer E2E's capture must survive the installed PM launcher's -I.""" + +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys + +import pytest + + +@pytest.mark.platforms("posix") +def test_isolated_pm_launch_captures_only_final_electron_spawn(tmp_path): + root = Path(__file__).resolve().parents[3] + helper = root / "tests/install/e2e-assets/launch-capture/pm-launch.py" + launcher = tmp_path / "hermes" + app = tmp_path / "app.py" + app.write_text( + "import os, subprocess, sys\n" + "assert sys.flags.isolated == 1\n" + "assert os.environ.get('PYTHONPATH') is None\n" + "assert sys.argv[1:] == ['desktop']\n" + "subprocess.run(['npm', 'run', 'build'], check=True)\n" + "subprocess.run(['npm', 'exec', '--', 'electron', '.'], " + "cwd=os.getcwd(), env={**os.environ, 'PRODUCT_SENTINEL': 'present'}, check=True)\n", + encoding="utf-8", + ) + # Query emulates the published --print-runtime-command interface. The + # returned command executes under a REAL isolated Python, not a patched + # subprocess; the fake npm command proves builds still pass through. + code = ("import os, runpy; os.environ.pop('PYTHONPATH', None); " + f"runpy.run_path({str(app)!r}, run_name='__main__')") + command = [sys.executable, "-I", "-c", code, "desktop"] + launcher.write_text( + "#!/bin/sh\n" + "[ \"$1\" = --print-runtime-command ] || exit 90\n" + f"printf '%s\\n' {shlex.quote(json.dumps(command))}\n", + encoding="utf-8", + ) + launcher.chmod(0o755) + fake_npm = tmp_path / "npm" + fake_npm.write_text( + "#!/bin/sh\n" + "[ \"$1 $2 $3\" = 'run build ' ] || exit 89\n" + f"touch {shlex.quote(str(tmp_path / 'build-ran'))}\n", + encoding="utf-8", + ) + fake_npm.chmod(0o755) + evil = tmp_path / "evil" + evil.mkdir() + (evil / "sitecustomize.py").write_text( + f"open({str(tmp_path / 'ambient-hook-ran')!r}, 'w').close()\n", encoding="utf-8", + ) + spec = tmp_path / "launch.json" + env = {**os.environ, "PATH": str(tmp_path) + os.pathsep + os.environ["PATH"], + "PYTHONPATH": str(evil)} + result = subprocess.run([sys.executable, "-I", str(helper), str(launcher), str(spec)], + cwd=tmp_path, env=env, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert (tmp_path / "build-ran").is_file() + assert not (tmp_path / "ambient-hook-ran").exists() + assert spec.with_name(spec.name + ".captured").read_text() == "source" + captured = json.loads(spec.read_text()) + assert captured["argv"] == ["npm", "exec", "--", "electron", "."] + assert captured["cwd"] == str(tmp_path) + assert captured["env"]["PRODUCT_SENTINEL"] == "present" + assert captured["matchedShape"] == "source"