Files
2026-07-13 13:12:33 +08:00

345 lines
13 KiB
Python

"""Build-mode verification: the fixed checklist decides the task state."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from opensquilla.contrib.codetask import build_verify
from opensquilla.contrib.codetask.types import TaskState
def _make_repo(tmp_path: Path, *, pkg: bool = True, lock: bool = True) -> Path:
tmp_path.mkdir(parents=True, exist_ok=True)
if pkg:
(tmp_path / "package.json").write_text("{}")
if lock:
(tmp_path / "package-lock.json").write_text("{}")
return tmp_path
def _ok(out: str = "ok") -> build_verify._CheckRun:
return build_verify._CheckRun(ran=True, exit_code=0, timed_out=False, output_tail=out)
def _fail(code: int = 1, out: str = "") -> build_verify._CheckRun:
return build_verify._CheckRun(ran=True, exit_code=code, timed_out=False, output_tail=out)
def test_missing_lockfile_is_environment_blocked(tmp_path):
repo = _make_repo(tmp_path, lock=False)
out = build_verify.verify_build(repo)
assert out.state is TaskState.ENVIRONMENT_BLOCKED
assert "package-lock.json" in out.detail
assert out.build.all_passed is False
assert out.build.checks == []
def test_all_checks_pass_is_verified(tmp_path, monkeypatch):
# Pin a deterministic host (linux) and provide its installer, exercising the
# "all checks pass + installer present -> VERIFIED" path. Every platform now
# requires its own installer; per-platform cases are covered below.
monkeypatch.setattr(build_verify.sys, "platform", "linux")
repo = _make_repo(tmp_path)
(repo / "dist").mkdir()
(repo / "dist" / "app-1.0.0.AppImage").write_text("y")
monkeypatch.setattr(build_verify, "_run_build_check", lambda *a, **k: _ok())
out = build_verify.verify_build(repo)
assert out.state is TaskState.VERIFIED
assert out.build.all_passed is True
assert [c.name for c in out.build.checks] == [
"npm_ci",
"build",
"package",
"runtime_deps",
]
assert all(c.ok and c.ran for c in out.build.checks)
assert out.build.installer_path.endswith("app-1.0.0.AppImage")
def test_npm_ci_failure_is_environment_blocked_and_stops_early(tmp_path, monkeypatch):
repo = _make_repo(tmp_path)
monkeypatch.setattr(build_verify, "_run_build_check", lambda *a, **k: _fail(1, "npm err"))
out = build_verify.verify_build(repo)
assert out.state is TaskState.ENVIRONMENT_BLOCKED
# stops at the first failing check
assert [c.name for c in out.build.checks] == ["npm_ci"]
assert out.build.checks[0].ok is False
assert out.build.all_passed is False
def test_build_failure_is_failed(tmp_path, monkeypatch):
repo = _make_repo(tmp_path)
monkeypatch.setattr(build_verify, "_resolve_cli", lambda name: name)
def fake_run(argv, **k):
if argv[:2] == ["npm", "run"]: # the build step
return _fail(1, "tsc error")
return _ok()
monkeypatch.setattr(build_verify, "_run_build_check", fake_run)
out = build_verify.verify_build(repo)
assert out.state is TaskState.FAILED
assert [c.name for c in out.build.checks] == ["npm_ci", "build"]
assert out.build.checks[-1].ok is False
assert "build" in out.detail
def test_package_failure_is_failed(tmp_path, monkeypatch):
repo = _make_repo(tmp_path)
monkeypatch.setattr(build_verify, "_resolve_cli", lambda name: name)
def fake_run(argv, **k):
if argv[0] == "npx": # electron-builder
return _fail(1, "packaging error")
return _ok()
monkeypatch.setattr(build_verify, "_run_build_check", fake_run)
out = build_verify.verify_build(repo)
assert out.state is TaskState.FAILED
assert [c.name for c in out.build.checks] == ["npm_ci", "build", "package"]
assert out.build.checks[-1].ok is False
def test_command_not_found_is_recorded(tmp_path, monkeypatch):
repo = _make_repo(tmp_path)
def fake_run(*a, **k):
return build_verify._CheckRun(
ran=False,
exit_code=None,
timed_out=False,
output_tail="",
error="command not found: npm",
)
monkeypatch.setattr(build_verify, "_run_build_check", fake_run)
out = build_verify.verify_build(repo)
assert out.state is TaskState.ENVIRONMENT_BLOCKED # npm_ci is the first check
assert out.build.checks[0].ran is False
assert "not found" in out.build.checks[0].raw_tail
def test_timeout_is_typed_and_reported(tmp_path, monkeypatch):
# A hung check must be distinguishable from a build that ran and failed.
repo = _make_repo(tmp_path)
def fake_run(argv, **k):
if argv[:2] == ["npm", "run"]:
return build_verify._CheckRun(
ran=True, exit_code=None, timed_out=True, output_tail="TIMEOUT after 1s"
)
return _ok()
monkeypatch.setattr(build_verify, "_resolve_cli", lambda name: name)
monkeypatch.setattr(build_verify, "_run_build_check", fake_run)
out = build_verify.verify_build(repo)
assert out.state is TaskState.FAILED
build_step = out.build.checks[-1]
assert build_step.name == "build"
assert build_step.timed_out is True
assert build_step.ok is False
assert "timed out" in out.detail
def test_resolve_cli_uses_desktop_node_bin_env(tmp_path, monkeypatch):
node_bin = tmp_path / "node-bin"
node_bin.mkdir()
npm = node_bin / ("npm.cmd" if sys.platform == "win32" else "npm")
npm.write_text("", encoding="utf-8")
npm.chmod(0o755)
monkeypatch.setenv("OPENSQUILLA_NODE_BIN_DIR", str(node_bin))
monkeypatch.setattr(build_verify.shutil, "which", lambda *a, **k: None)
assert build_verify._resolve_cli("npm") == str(npm)
def test_verify_build_prepends_desktop_node_bin_to_child_path(tmp_path, monkeypatch):
repo = _make_repo(tmp_path / "repo")
node_bin = tmp_path / "node-bin"
node_bin.mkdir()
monkeypatch.setenv("OPENSQUILLA_NODE_BIN_DIR", str(node_bin))
monkeypatch.setattr(build_verify, "_resolve_cli", lambda name: name)
seen_paths: list[str] = []
def fake_run(argv, *, cwd, env, timeout):
seen_paths.append(env["PATH"])
return _fail(1, "stop after first check")
monkeypatch.setattr(build_verify, "_run_build_check", fake_run)
build_verify.verify_build(repo)
assert seen_paths
assert seen_paths[0].split(build_verify.os.pathsep)[0] == str(node_bin)
def test_verify_build_env_is_non_interactive(tmp_path, monkeypatch):
# Every verification child must run non-interactively: a prompting build
# tool should see CI/npm auto-confirm rather than block on the caller's TTY.
repo = _make_repo(tmp_path)
monkeypatch.setattr(build_verify, "_resolve_cli", lambda name: name)
seen_env: list[dict[str, str]] = []
def fake_run(argv, *, cwd, env, timeout):
seen_env.append(env)
return _fail(1)
monkeypatch.setattr(build_verify, "_run_build_check", fake_run)
build_verify.verify_build(repo)
assert seen_env
env = seen_env[0]
assert env["CI"] == "1"
assert env["npm_config_yes"] == "true"
assert env["CSC_IDENTITY_AUTO_DISCOVERY"] == "false"
def test_run_build_check_closes_stdin_and_is_bounded(tmp_path):
# Real child: a process that tries to read stdin must get EOF (DEVNULL) and
# exit promptly rather than blocking; a genuinely hung child must be killed
# at the deadline and reported as timed_out.
repo = _make_repo(tmp_path)
reads_stdin = [
sys.executable,
"-c",
"import sys; data = sys.stdin.read(); print('read', len(data)); sys.exit(0)",
]
run = build_verify._run_build_check(
reads_stdin, cwd=repo, env=dict(build_verify.os.environ), timeout=30
)
assert run.ran is True
assert run.timed_out is False
assert run.exit_code == 0
hangs = [sys.executable, "-c", "import time; time.sleep(30)"]
run = build_verify._run_build_check(
hangs, cwd=repo, env=dict(build_verify.os.environ), timeout=1
)
assert run.timed_out is True
assert run.exit_code is None
assert "TIMEOUT" in run.output_tail
def test_package_step_is_mac_dmg_on_darwin(monkeypatch):
monkeypatch.setattr(build_verify.sys, "platform", "darwin")
name, argv = build_verify._package_step()
assert name == "package" and "--mac" in argv and "dmg" in argv
def test_package_step_is_linux_appimage_on_linux(monkeypatch):
# Linux builds a real, tooling-free installer (AppImage), not just --dir.
monkeypatch.setattr(build_verify.sys, "platform", "linux")
name, argv = build_verify._package_step()
assert "--linux" in argv and "AppImage" in argv and "--dir" not in argv
def test_package_step_is_win_nsis_on_win32(monkeypatch):
monkeypatch.setattr(build_verify.sys, "platform", "win32")
monkeypatch.setattr(build_verify, "_resolve_cli", lambda name: name)
name, argv = build_verify._package_step()
assert name == "package" and "--win" in argv and "nsis" in argv
def test_find_installers_finds_appimage_and_deb_on_linux(tmp_path, monkeypatch):
monkeypatch.setattr(build_verify.sys, "platform", "linux")
(tmp_path / "dist").mkdir()
(tmp_path / "dist" / "app-1.0.0.AppImage").write_text("y")
(tmp_path / "dist" / "app_1.0.0_amd64.deb").write_text("y")
(tmp_path / "dist" / "app-1.0.0.dmg").write_text("y") # wrong platform: ignore
got = build_verify._find_installers(tmp_path)
assert len(got) == 2
assert any(p.endswith(".AppImage") for p in got)
assert any(p.endswith(".deb") for p in got)
assert not any(p.endswith(".dmg") for p in got)
def test_find_installers_finds_exe_and_skips_unpacked_on_win(tmp_path, monkeypatch):
monkeypatch.setattr(build_verify.sys, "platform", "win32")
(tmp_path / "dist").mkdir()
(tmp_path / "dist" / "App Setup 1.0.0.exe").write_text("y") # the installer
unpacked = tmp_path / "dist" / "win-unpacked"
unpacked.mkdir()
(unpacked / "App.exe").write_text("y") # raw app exe — must NOT be picked up
got = build_verify._find_installers(tmp_path)
assert got == [str(tmp_path / "dist" / "App Setup 1.0.0.exe")]
def test_find_installers_lists_all_dmgs_on_darwin(tmp_path, monkeypatch):
monkeypatch.setattr(build_verify.sys, "platform", "darwin")
(tmp_path / "dist").mkdir()
(tmp_path / "dist" / "app-arm64.dmg").write_text("y")
(tmp_path / "dist" / "app-x64.dmg").write_text("y")
got = build_verify._find_installers(tmp_path)
assert len(got) == 2 and all(p.endswith(".dmg") for p in got)
@pytest.mark.skipif(sys.platform == "win32", reason="code-task Windows support is WIP")
def test_find_installers_finds_dmg_in_custom_output_dir_on_darwin(tmp_path, monkeypatch):
# electron-builder `directories.output: release` (not the default dist/).
monkeypatch.setattr(build_verify.sys, "platform", "darwin")
(tmp_path / "release").mkdir()
(tmp_path / "release" / "app-1.0.0-arm64.dmg").write_text("y")
got = build_verify._find_installers(tmp_path)
assert len(got) == 1 and got[0].endswith("release/app-1.0.0-arm64.dmg")
def test_find_installers_ignores_node_modules_dmgs(tmp_path, monkeypatch):
monkeypatch.setattr(build_verify.sys, "platform", "darwin")
nm = tmp_path / "node_modules" / "some-pkg"
nm.mkdir(parents=True)
(nm / "vendored.dmg").write_text("y") # must NOT be picked up
(tmp_path / "release").mkdir()
(tmp_path / "release" / "real.dmg").write_text("y")
got = build_verify._find_installers(tmp_path)
assert got == [str(tmp_path / "release" / "real.dmg")]
def test_clean_package_without_installer_is_failed(tmp_path, monkeypatch):
repo = _make_repo(tmp_path)
monkeypatch.setattr(build_verify.sys, "platform", "darwin")
monkeypatch.setattr(build_verify, "_run_build_check", lambda *a, **k: _ok())
out = build_verify.verify_build(repo) # all steps exit 0 but no installer
assert out.state is TaskState.FAILED
assert "no installer" in out.detail
assert out.build.all_passed is False
def test_darwin_package_with_dmg_is_verified(tmp_path, monkeypatch):
repo = _make_repo(tmp_path)
monkeypatch.setattr(build_verify.sys, "platform", "darwin")
monkeypatch.setattr(build_verify, "_run_build_check", lambda *a, **k: _ok())
(repo / "dist").mkdir()
(repo / "dist" / "app-1.0.0.dmg").write_text("y")
out = build_verify.verify_build(repo)
assert out.state is TaskState.VERIFIED
assert out.build.installer_path.endswith("app-1.0.0.dmg")
assert out.build.installer_paths == [out.build.installer_path]
def test_win_package_with_exe_is_verified(tmp_path, monkeypatch):
repo = _make_repo(tmp_path)
monkeypatch.setattr(build_verify.sys, "platform", "win32")
monkeypatch.setattr(build_verify, "_resolve_cli", lambda name: name)
monkeypatch.setattr(build_verify, "_run_build_check", lambda *a, **k: _ok())
(repo / "dist").mkdir()
(repo / "dist" / "App Setup 1.0.0.exe").write_text("y")
out = build_verify.verify_build(repo)
assert out.state is TaskState.VERIFIED
assert out.build.installer_path.endswith(".exe")
def test_linux_package_with_appimage_is_verified(tmp_path, monkeypatch):
repo = _make_repo(tmp_path)
monkeypatch.setattr(build_verify.sys, "platform", "linux")
monkeypatch.setattr(build_verify, "_run_build_check", lambda *a, **k: _ok())
(repo / "dist").mkdir()
(repo / "dist" / "app-1.0.0.AppImage").write_text("y")
out = build_verify.verify_build(repo)
assert out.state is TaskState.VERIFIED
assert out.build.installer_path.endswith(".AppImage")