Files
hermes-agent/tests/scripts/test_termux_build_driver.py
ethernet 75a13386fb fix: make macOS shell and mode contracts portable
The macOS runner uses Bash 3.2, which rejects parameter case conversion.
Normalize the GHCR owner with portable tr and verify mixed-case input.

The macOS runner can clear setgid from a directory when chmod applies 2770.
Compare scratch permissions with the native chmod result while preserving all
bits the host accepts.

Tests: scripts/run_tests.sh tests/scripts/test_termux_build_driver.py tests/test_scratch_dir.py
Shell: bash -n scripts/termux/build_builder_image.sh
2026-09-22 00:09:09 -04:00

223 lines
9.5 KiB
Python

"""Exercise the Termux sequencing boundary with real child executables."""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import pytest
from ruamel.yaml import YAML
ROOT = Path(__file__).resolve().parents[2]
DRIVER = ROOT / "scripts/termux/build.py"
pytestmark = pytest.mark.platforms("posix")
@pytest.fixture
def build_fixture(tmp_path):
repo = tmp_path / "source with spaces"
payload = tmp_path / "prepared payload"
out = tmp_path / "deb output"
tools = tmp_path / "tools"
tools.mkdir()
payload.mkdir()
for name in ("scripts/build/node-deps.mjs", "scripts/build/tui.mjs", "scripts/termux/build_deb.sh"):
script = repo / name
script.parent.mkdir(parents=True, exist_ok=True)
script.touch()
log = tmp_path / "calls.jsonl"
recorder = f"""#!{sys.executable}
import json
import os
from pathlib import Path
import sys
args = sys.argv[1:]
assert Path(args[0]).is_file(), args
stage = Path(args[0]).name
with Path(os.environ['CALL_LOG']).open('a') as stream:
stream.write(json.dumps({{'tool': Path(sys.argv[0]).name, 'args': args,
'cwd': os.getcwd(), 'image': os.environ.get('TERMUX_BUILDER_IMAGE')}}) + '\\n')
if stage == os.environ.get('FAIL_STAGE'):
sys.exit(37)
if stage == 'tui.mjs':
product = Path(args[args.index('--out') + 1])
(product / 'dist').mkdir(parents=True, exist_ok=True)
(product / 'dist/entry.js').write_text('fixture TUI product')
(product / 'package.json').write_text('{{"type":"module"}}')
if stage == 'build_deb.sh':
product = Path(args[args.index('--tui-product') + 1])
assert (product / 'dist/entry.js').read_text() == 'fixture TUI product'
destination = Path(args[args.index('--out') + 1])
destination.mkdir(parents=True)
(destination / 'assembly-received-product').write_text(str(product))
"""
for name in ("node", "bash"):
tool = tools / name
tool.write_text(recorder, encoding="utf-8")
tool.chmod(0o755)
env = dict(os.environ, PATH=f"{tools}{os.pathsep}{os.environ['PATH']}",
CALL_LOG=str(log), TERMUX_BUILDER_IMAGE="fixture-pinned-image")
return repo, payload, out, log, env
def invoke(build_fixture, *identity, extra=()):
repo, payload, out, _, env = build_fixture
return subprocess.run(
[sys.executable, str(DRIVER), "--repo", str(repo), "--payload", str(payload),
"--out", str(out), *identity, *extra],
cwd=repo.parent, env=env, capture_output=True, text=True, timeout=30,
)
def recorded_calls(log):
return [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else []
@pytest.mark.parametrize("identity", [("--tag", "v1.2.3"), ("--commit", "a" * 40)])
def test_driver_prepares_only_tui_then_passes_product_to_deb(build_fixture, identity):
repo, payload, out, log, _ = build_fixture
result = invoke(build_fixture, *identity)
assert result.returncode == 0, result.stdout + result.stderr
product = repo / ".build/termux/tui"
assert recorded_calls(log) == [
{"tool": "node", "args": [str(repo / "scripts/build/node-deps.mjs"),
"--source", str(repo), "--workspace", "ui-tui"],
"cwd": str(repo), "image": "fixture-pinned-image"},
{"tool": "node", "args": [str(repo / "scripts/build/tui.mjs"),
"--source", str(repo), "--out", str(product)],
"cwd": str(repo), "image": "fixture-pinned-image"},
{"tool": "bash", "args": [str(repo / "scripts/termux/build_deb.sh"),
"--repo", str(repo), "--payload", str(payload), "--out", str(out),
"--tui-product", str(product), *identity],
"cwd": str(repo), "image": "fixture-pinned-image"},
]
assert (out / "assembly-received-product").read_text() == str(product)
@pytest.mark.parametrize("identity", [
(), ("--tag", "v1.2.3", "--commit", "a" * 40),
("--ta", "v1.2.3"), ("--commit", "short"),
("--commit", "A" * 40), ("--tag", ""), ("--tag", "latest"),
("--tag", "v1.2.3", "--skip-build"),
])
def test_invalid_cli_never_starts_a_build(build_fixture, identity):
_, payload, out, log, _ = build_fixture
result = invoke(build_fixture, *identity)
assert result.returncode == 2, result.stdout + result.stderr
assert "error:" in result.stderr
assert recorded_calls(log) == []
assert not (payload / ".work").exists()
assert not out.exists()
@pytest.mark.parametrize("missing", ["--repo", "--payload", "--out"])
def test_all_paths_are_explicit(build_fixture, missing):
repo, payload, out, log, env = build_fixture
paths = {"--repo": repo, "--payload": payload, "--out": out}
args = [value for option, path in paths.items() if option != missing
for value in (option, str(path))]
result = subprocess.run([sys.executable, str(DRIVER), *args, "--tag", "v1.2.3"],
cwd=repo.parent, env=env, capture_output=True, text=True, timeout=30)
assert result.returncode == 2
assert missing in result.stderr
assert recorded_calls(log) == []
def test_missing_prepared_payload_is_rejected_before_frontend_work(build_fixture):
_, payload, out, log, _ = build_fixture
payload.rmdir()
result = invoke(build_fixture, "--tag", "v1.2.3")
assert result.returncode == 2, result.stdout + result.stderr
assert "--payload" in result.stderr
assert recorded_calls(log) == []
assert not payload.exists()
assert not out.exists()
@pytest.mark.parametrize("stage, reached", [
("node-deps.mjs", ["node-deps.mjs"]),
("tui.mjs", ["node-deps.mjs", "tui.mjs"]),
("build_deb.sh", ["node-deps.mjs", "tui.mjs", "build_deb.sh"]),
])
def test_child_failure_stops_sequence_even_with_stale_product(build_fixture, stage, reached):
repo, payload, out, log, env = build_fixture
stale = repo / ".build/termux/tui/dist/entry.js"
stale.parent.mkdir(parents=True)
stale.write_text("stale product")
env["FAIL_STAGE"] = stage
result = invoke(build_fixture, "--tag", "v1.2.3")
assert result.returncode == 37, result.stdout + result.stderr
assert [Path(call["args"][0]).name for call in recorded_calls(log)] == reached
assert not out.exists()
@pytest.mark.parametrize("identity", [("--tag", "v1.2.3"), ("--commit", "a" * 40)])
def test_release_workflow_runs_the_shared_sequence(build_fixture, identity):
repo, payload, _, log, env = build_fixture
(repo / "scripts/termux/build.py").symlink_to(DRIVER)
(repo / "termux-build").mkdir()
(repo / "termux-build/payload").symlink_to(payload, target_is_directory=True)
tools = log.parent / "tools"
(tools / "python3").symlink_to(sys.executable)
env.update(HERMES_BUILD_COMMIT=identity[1] if identity[0] == "--commit" else "",
HERMES_PAYLOAD_TAG=identity[1] if identity[0] == "--tag" else "")
workflow = YAML(typ="safe").load((ROOT / ".github/workflows/desktop-bundled-release.yml").read_text())
step = next(step for step in workflow["jobs"]["termux-deb"]["steps"]
if step.get("name") == "Assemble the .deb")
bash = shutil.which("bash")
assert bash is not None
result = subprocess.run([bash, "-euo", "pipefail", "-c", step["run"]],
cwd=repo, env=env, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
calls = recorded_calls(log)
assert [Path(call["args"][0]).name for call in calls] == [
"node-deps.mjs", "tui.mjs", "build_deb.sh",
]
assert calls[-1]["args"][-2:] == list(identity)
assert (repo / "termux-build/deb/assembly-received-product").read_text() == str(
repo / ".build/termux/tui")
@pytest.mark.parametrize("changed_input", ["Dockerfile", "base"])
def test_builder_image_identity_covers_all_inputs(tmp_path, changed_input):
repo = tmp_path / "source with spaces"
scripts = repo / "scripts/termux"
scripts.mkdir(parents=True)
for name in ("build_builder_image.sh", "termux-builder.Dockerfile"):
shutil.copy2(ROOT / "scripts/termux" / name, scripts / name)
shutil.copytree(ROOT / "pm", repo / "pm", ignore=shutil.ignore_patterns("__pycache__"))
tools = tmp_path / "tools"
tools.mkdir()
(tools / "python3").symlink_to(sys.executable)
# Only inspect an already-published image; any build/push call is a failure.
command = 'docker() { [[ "$1 $2" == "manifest inspect" ]]; }; export -f docker; bash "$1"'
env = {**os.environ, "PATH": f"{tools}{os.pathsep}{os.environ['PATH']}",
"GITHUB_REPOSITORY_OWNER": "FiXtUrE"}
def image():
result = subprocess.run(["bash", "-c", command, "fixture", str(scripts / "build_builder_image.sh")],
env=env, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
return result.stdout.splitlines()[-1]
original = image()
assert original.startswith("ghcr.io/fixture/hermes-termux-builder:")
assert image() == original
changed = scripts / "termux-builder.Dockerfile" if changed_input == "Dockerfile" else repo / "pm/lock.json"
before = changed.read_bytes()
if changed_input == "Dockerfile":
changed.write_bytes(before + b"\nRUN apt install -y git\n")
else:
pin = json.loads(before)
digest = pin["packages"]["termux-docker"]["version"]
pin["packages"]["termux-docker"]["version"] = digest[:-1] + ("a" if digest[-1] != "a" else "b")
changed.write_text(json.dumps(pin))
assert image() != original
changed.write_bytes(before)
assert image() == original