Build TUI, web, desktop UI and runnable agent products from explicit prepared inputs. Keep dependency preparation separate from distribution packaging, with PM and native builds sharing uv environment construction. Docker copies compiled frontend products instead of build dependencies. Nix retains uv2nix environments and consumes shared assembly through store references. Native desktop and Termux use the same launcher and frontend contracts. Preserve the independent PM runtime and source imports from arbitrary working directories. Keep failed frontend builds from replacing the previous product, reject source/output overlap, and bound dependency-process output draining. Include hermes_wisdom in the Nix wheel: real CLI smoke tests exposed its missing package declaration on the base revision too. Verified focused Python and JavaScript suites, Docker build/runtime checks, Nix desktop and CLI/ACP checks, standalone TUI and packaged Electron PTY, and real full-Chromium interaction. Native signed installers, Android device installation and the full repository suite remain CI verification.
184 lines
7.6 KiB
Python
184 lines
7.6 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")
|