test: capture PM desktop launches under isolated interpreter
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
35
tests/install/e2e-assets/launch-capture/pm-launch.py
Normal file
35
tests/install/e2e-assets/launch-capture/pm-launch.py
Normal file
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
68
tests/scripts/install/test_pm_desktop_launch_capture.py
Normal file
68
tests/scripts/install/test_pm_desktop_launch_capture.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user