The current source checker reports updateAvailable with behind null when GitHub compare cannot count staged commits; the app-update predicate demanded an integer behind and refused every HEAD->NEXT leg. Require behind > 0 only for the historical shape without updateAvailable. v2026.6.19's DMG bootstrap runs the same install.sh that writes .install_method, so its macOS leg saw a dirty tree. Share the Linux driver's guarded exclude through source-driver.sh and apply it before every app-driven macOS update.
246 lines
12 KiB
Python
246 lines
12 KiB
Python
"""Cheap source-driver checks: real shells, disposable installs, no installer."""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import runpy
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import sysconfig
|
|
|
|
import pytest
|
|
|
|
|
|
ASSETS = Path(__file__).resolve().parents[1] / "install/e2e-assets"
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_shell_selects_the_installed_command_not_the_phase_or_path(tmp_path):
|
|
root = tmp_path / "installed source"
|
|
legacy = root / "venv/bin/hermes"
|
|
published = root / ".hermes/bin/hermes"
|
|
for command, label in ((legacy, "legacy"), (published, "published")):
|
|
command.parent.mkdir(parents=True)
|
|
command.write_text(f'#!/bin/sh\nprintf "{label}:%s\\n" "$*"\n', encoding="utf-8")
|
|
command.chmod(0o755)
|
|
env = dict(os.environ, INSTALL_DIR=str(root), ASSETS=str(ASSETS), HOME=str(tmp_path))
|
|
result = subprocess.run(["bash", "-euc", '''
|
|
source "$ASSETS/source-driver.sh"
|
|
command=$(source_hermes "$INSTALL_DIR")
|
|
"$command" 'literal argument'
|
|
rm "$INSTALL_DIR/venv/bin/hermes"
|
|
test "$(source_hermes "$INSTALL_DIR")" = "$command"
|
|
printf '#!/bin/sh\nexit 23\n' > "$command"
|
|
"$command" || test "$?" = 23
|
|
rm "$command"
|
|
if source_hermes "$INSTALL_DIR"; then exit 91; fi
|
|
printf '#!/bin/sh\nprintf legacy\n' > "$INSTALL_DIR/venv/bin/hermes"
|
|
chmod +x "$INSTALL_DIR/venv/bin/hermes"
|
|
"$(source_hermes "$INSTALL_DIR")"
|
|
# A PM checkout cannot hide missing publication behind its old venv.
|
|
mkdir -p "$INSTALL_DIR/pm"
|
|
printf '{}' > "$INSTALL_DIR/pm/lock.json"
|
|
if source_hermes "$INSTALL_DIR"; then exit 92; fi
|
|
'''], env=env, cwd=tmp_path, capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert result.stdout.splitlines() == ["published:literal argument", "legacy"]
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_installer_marker_is_the_only_dirty_state_a_driver_accepts(tmp_path):
|
|
root = tmp_path / "installed source"
|
|
root.mkdir()
|
|
git = ["git", "-C", str(root), "-c", "user.name=t", "-c", "user.email=t@t"]
|
|
subprocess.run([*git, "init", "-q"], check=True)
|
|
(root / "tracked").write_text("v1\n", encoding="utf-8")
|
|
subprocess.run([*git, "add", "tracked"], check=True)
|
|
subprocess.run([*git, "commit", "-qm", "base"], check=True)
|
|
(root / ".install_method").write_text("git\n", encoding="utf-8")
|
|
env = dict(os.environ, INSTALL_DIR=str(root), ASSETS=str(ASSETS), HOME=str(tmp_path))
|
|
|
|
def accept():
|
|
return subprocess.run(["bash", "-euc", 'source "$ASSETS/source-driver.sh"; accept_installer_marker "$INSTALL_DIR"'],
|
|
env=env, capture_output=True, text=True, timeout=30)
|
|
|
|
assert accept().returncode == 0
|
|
assert (root / ".install_method").read_text(encoding="utf-8") == "git\n"
|
|
assert subprocess.run([*git, "status", "--porcelain", "--untracked-files=all"],
|
|
capture_output=True, text=True, check=True).stdout == ""
|
|
(root / "tracked").write_text("edited\n", encoding="utf-8")
|
|
refused = accept()
|
|
assert refused.returncode != 0 and "M tracked" in refused.stderr
|
|
|
|
|
|
@pytest.mark.platforms("windows", "posix")
|
|
def test_powershell_selects_exact_exe_or_cmd_and_legacy_fallback(tmp_path):
|
|
# These are file-selection rules, not Windows execution emulation. Run
|
|
# them in real PowerShell on any host that supplies it (native CI included).
|
|
path = os.pathsep.join(p for p in os.get_exec_path() if ".hermes" not in Path(p).parts)
|
|
pwsh = shutil.which("pwsh", path=path) or shutil.which("powershell", path=path)
|
|
if not pwsh:
|
|
if os.name == "nt":
|
|
pytest.fail("native Windows acceptance requires PowerShell")
|
|
pytest.skip("PowerShell is not available on this host")
|
|
root = tmp_path / "installed source"
|
|
root.mkdir()
|
|
harness = tmp_path / "probe.ps1"
|
|
harness.write_text('''$ErrorActionPreference = 'Stop'
|
|
. (Join-Path $env:ASSETS 'source-driver.ps1')
|
|
$root = $env:INSTALL_DIR
|
|
$legacy = Join-Path $root 'venv/Scripts/hermes.exe'
|
|
$exe = Join-Path $root '.hermes/bin/hermes.exe'
|
|
$cmd = Join-Path $root '.hermes/bin/hermes.cmd'
|
|
New-Item -ItemType Directory -Path (Split-Path $legacy), (Split-Path $exe) -Force | Out-Null
|
|
Set-Content $legacy 'legacy'
|
|
if ((Get-SourceHermes $root) -ne $legacy) { throw 'legacy fallback' }
|
|
Set-Content $cmd 'cmd'
|
|
if ((Get-SourceHermes $root) -ne $cmd) { throw 'published cmd' }
|
|
Set-Content $exe 'exe'
|
|
if ((Get-SourceHermes $root) -ne $exe) { throw 'exe precedence' }
|
|
Remove-Item $legacy
|
|
if ((Get-SourceHermes $root) -ne $exe) { throw 'requires legacy venv' }
|
|
Remove-Item $exe, $cmd
|
|
$refused = $false
|
|
try { Get-SourceHermes $root } catch { $refused = $true }
|
|
if (-not $refused) { throw 'missing command accepted' }
|
|
Set-Content $legacy 'stale legacy'
|
|
New-Item -ItemType Directory -Path (Join-Path $root 'pm') | Out-Null
|
|
Set-Content (Join-Path $root 'pm/lock.json') '{}'
|
|
$refused = $false
|
|
try { Get-SourceHermes $root } catch { $refused = $true }
|
|
if (-not $refused) { throw 'missing PM launcher accepted' }
|
|
Write-Output 'selection verified'
|
|
''', encoding="utf-8")
|
|
result = subprocess.run([pwsh, "-NoProfile", "-NonInteractive", "-File", str(harness)],
|
|
env=dict(os.environ, INSTALL_DIR=str(root), ASSETS=str(ASSETS), HOME=str(tmp_path)),
|
|
cwd=tmp_path, capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert result.stdout.strip() == "selection verified"
|
|
|
|
|
|
def test_observer_preserves_no_desktop_and_refuses_incomplete_app(tmp_path):
|
|
verify = runpy.run_path(str(ASSETS / "source_driver.py"))["verify_products"]
|
|
root = tmp_path / "source"
|
|
root.mkdir()
|
|
verify(root, "absent")
|
|
assert list(root.iterdir()) == []
|
|
with pytest.raises(RuntimeError, match="desktop"):
|
|
verify(root, "present")
|
|
release = root / "apps/desktop/release/linux-unpacked"
|
|
release.mkdir(parents=True)
|
|
(release / "hermes").write_bytes(b"incomplete fixture")
|
|
with pytest.raises(RuntimeError, match="desktop"):
|
|
verify(root, "absent")
|
|
with pytest.raises(RuntimeError, match="desktop"):
|
|
verify(root, "present")
|
|
assert (release / "hermes").read_bytes() == b"incomplete fixture"
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.parametrize("fault,error", [
|
|
("uv-lock", "dependency generation is not current"),
|
|
("no-desktop", "dependency generation is not current"),
|
|
("foreign-launcher", "belongs to another installation"),
|
|
("missing-launcher", ""),
|
|
("incomplete", "incomplete"),
|
|
("web-changed", "web output"), ("web-missing", "web output"),
|
|
])
|
|
def test_pm_observer_accepts_ready_fixture_and_leaves_failed_fixture_untouched(tmp_path, fault, error):
|
|
# This exercises the complete observer process, not a whole install. The
|
|
# fixture borrows prepared test dependencies and records real PM input
|
|
# stamps / compiler receipts. It never resolves or acquires a package.
|
|
repo = ASSETS.parents[2]
|
|
root = tmp_path / "source"
|
|
for directory in ("pm", "hermes_cli"):
|
|
shutil.copytree(repo / directory, root / directory, ignore=shutil.ignore_patterns("__pycache__"))
|
|
shutil.copy2(repo / "hermes_constants.py", root / "hermes_constants.py")
|
|
(root / "hermes_bootstrap.py").write_text("raise RuntimeError('bootstrap must not run')", encoding="utf-8")
|
|
(root / "uv.lock").write_text("fixture locked graph", encoding="utf-8")
|
|
home = tmp_path / "home"
|
|
store = tmp_path / "store"
|
|
env = dict(os.environ, HOME=str(home), HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(store),
|
|
PYTHONDONTWRITEBYTECODE="1", HERMES_DISABLE_LAZY_INSTALLS="1")
|
|
env.pop("HERMES_INSTALL_ROOT", None)
|
|
path = os.pathsep.join(p for p in os.get_exec_path() if ".hermes" not in Path(p).parts)
|
|
node = shutil.which("node", path=path)
|
|
assert node
|
|
setup = '''import sys
|
|
from pathlib import Path
|
|
root, store, node, deps = map(Path, sys.argv[1:])
|
|
sys.path.insert(0, str(root))
|
|
from pm.lock import Facts, Lockfile
|
|
from pm.packages import Venv
|
|
from pm.environments import install_state_dir, runtime_facts_path, site_packages
|
|
from hermes_cli._launchers import ensure_install_launchers
|
|
lock = Lockfile(root / 'pm/lock.json')
|
|
lock.set_pin('node', 'fixture', {})
|
|
lock.save()
|
|
for name, executable in [('python', Path(sys.executable)), ('node', node)]:
|
|
binary = store / name / 'bin' / ('python3' if name == 'python' else name)
|
|
binary.parent.mkdir(parents=True)
|
|
binary.symlink_to(executable)
|
|
Facts(store / 'facts.json').record(name, 'fixture', name, {}, store)
|
|
selected = install_state_dir(root) / 'environments/fixture/venv'
|
|
selected.mkdir(parents=True)
|
|
(selected / 'pyvenv.cfg').write_text('home = fixture')
|
|
site = site_packages(selected)
|
|
site.parent.mkdir(parents=True)
|
|
site.symlink_to(deps, target_is_directory=True)
|
|
Facts(runtime_facts_path(root)).record_state('venv', Venv(root).expected_stamp([]), [], environment=selected)
|
|
ensure_install_launchers(root, root / '.hermes/bin')
|
|
'''
|
|
subprocess.run([sys.executable, "-I", "-B", "-c", setup, str(root), str(store), node,
|
|
sysconfig.get_path("purelib")], env=env, cwd=tmp_path, check=True, timeout=60)
|
|
build = root / "scripts/build"
|
|
build.mkdir(parents=True)
|
|
for name in ("freshness.mjs", "frontend-common.mjs"):
|
|
shutil.copy2(repo / "scripts/build" / name, build / name)
|
|
record = '''import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { buildInputs, recordProduct } from './scripts/build/freshness.mjs';
|
|
const source = process.cwd();
|
|
for (const [product, out] of [['tui', 'ui-tui/dist'], ['web', 'hermes_cli/web_dist'], ['desktop', 'apps/desktop/release/linux-unpacked/resources/app.asar.unpacked/dist']]) {
|
|
mkdirSync(out, { recursive: true }); writeFileSync(out + '/index.html', 'fixture product');
|
|
recordProduct({source, product, out, inputs: buildInputs(source, product)});
|
|
}
|
|
'''
|
|
subprocess.run([node, "--input-type=module", "-e", record], cwd=root, env=env, check=True, timeout=30)
|
|
(root / "apps/desktop/release/linux-unpacked/hermes").write_bytes(b"fixture executable")
|
|
command = [sys.executable, "-B", str(ASSETS / "source_driver.py"), "--root", str(root),
|
|
"--launcher", str(root / ".hermes/bin/hermes"), "--desktop", "present"]
|
|
if fault == "no-desktop":
|
|
shutil.rmtree(root / "apps/desktop")
|
|
command[-1] = "absent"
|
|
# The passive query must not write even import caches: -I ignores the
|
|
# environment's bytecode switch, so the published query enforces it.
|
|
def snapshot():
|
|
return {p: (hashlib.sha256(p.read_bytes()).hexdigest(), p.stat().st_mtime_ns) for tree in (root, home, store)
|
|
for p in tree.rglob("*") if p.is_file()}
|
|
before = snapshot()
|
|
result = subprocess.run(command, env=env, cwd=tmp_path, capture_output=True, text=True, timeout=60)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert snapshot() == before
|
|
if fault in {"uv-lock", "no-desktop"}:
|
|
(root / "uv.lock").write_text("changed graph without preparation", encoding="utf-8")
|
|
elif fault == "foreign-launcher":
|
|
foreign = tmp_path / "foreign"
|
|
shutil.copytree(root, foreign)
|
|
from hermes_cli._launchers import mint_launcher
|
|
launcher = mint_launcher("hermes", foreign, root / ".hermes/bin", Path(sys.executable), None)
|
|
assert launcher is not None
|
|
elif fault == "missing-launcher":
|
|
(root / ".hermes/bin/hermes").unlink()
|
|
elif fault == "incomplete":
|
|
(root / ".update-incomplete").write_text("incomplete", encoding="utf-8")
|
|
else:
|
|
damaged = root / "hermes_cli/web_dist/index.html"
|
|
if fault == "web-missing":
|
|
damaged.unlink()
|
|
else:
|
|
damaged.write_bytes(b"damaged")
|
|
before = snapshot()
|
|
result = subprocess.run(command, env=env, cwd=tmp_path, capture_output=True, text=True, timeout=60)
|
|
assert result.returncode != 0
|
|
assert error in result.stderr
|
|
assert "bootstrap must not run" not in result.stderr
|
|
assert snapshot() == before |