"""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")