Files
chemenu/tools/chemenu/tests/test_preflight_pwsh.py
T
torbenandClaude Opus 5.5 5994b2a88c
CI / verify (push) Successful in 8m8s
CI / pwsh (push) Successful in 2m40s
Release / release (push) Successful in 35s
Release / wiki-mirror (push) Skipped
raw: fetch behind a login - raw login stores the user's session as a secret outside the tree, fetch.routes sends a host through it or to an external fetcher, a handler's render goes through the optional browser component (preflight --with browser) (#169); 10.0.0-beta.8
Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- docs/how-to/README.md
- docs/how-to/fetch-pages-behind-a-login.md
- docs/how-to/ingest-a-source.md
- docs/reference/commands/README.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/raw-material-and-uploads.md
- docs/reference/configuration.md
- docs/reference/exit-codes.md
- instructions/preflight.md
- instructions/wiki-ingest/SKILL.md
- raw/CONTRACT.md
- sites/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/browser.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/raw/__init__.py
- tools/chemenu/commands/raw/fetch.py
- tools/chemenu/commands/raw/login.py
- tools/chemenu/config_shapes.py
- tools/chemenu/fetch_routes.py
- tools/chemenu/reference_doc.py
- tools/chemenu/settings.py
- tools/chemenu/sites/__init__.py
- tools/chemenu/sites/data.py
- tools/chemenu/sites/site.schema.json
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_config_shapes.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_preflight.py
- tools/chemenu/tests/test_preflight_pwsh.py
- tools/chemenu/tests/test_raw_fetch_routes.py
- tools/chemenu/tests/test_raw_login.py
- tools/chemenu/tests/test_site_files.py
- tools/chemenu/web_capture.py
- tools/chemenu/web_session.py
- tools/preflight.ps1
- tools/preflight.sh
- tools/requirements-browser.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-11 20:50:23 +02:00

623 lines
27 KiB
Python

"""tools/preflight.ps1 and tools/wikitool.ps1 (Gitea #151, section B).
The PowerShell twin of `test_preflight.py`, run against the same stub machine: a
`PATH` the test builds, stub tools in it, and a fake Python that answers the probe
and fakes `-m venv`/`-m pip`. The scripts need PowerShell 7, so these tests skip
where there is none and run in CI's `pwsh` job, whose image carries it; everything
Windows-specific (policy, Mark of the Web, path limit, Store alias) is driven by the
same kind of environment hook the shell script has.
What these add to the shell tests is the one thing only the twin can break: the two
scripts must record the same file. `test_both_preflights_record_the_same_file`
compares them byte for byte.
"""
from __future__ import annotations
import functools
import http.server
import json
import os
import shutil
import subprocess
import threading
import time
from pathlib import Path
import pytest
from chemenu import prerequisites
from chemenu.tests.test_preflight import (
ARCHIVED_TOOLS, DOWNLOAD_BASE, ECHO_PYTHON, MOVE_ATTEMPTS, MOVED_FIRST, SHELLS, TOOLS, AssetMachine,
Machine, _machine_with_root_of, add_to_release, assert_guidance, folder_state, tools_as_unpacked,
)
PWSH = shutil.which("pwsh")
pytestmark = pytest.mark.skipif(PWSH is None, reason="PowerShell 7 (pwsh) is not installed")
WINDOWS = {"CHEMENU_PREFLIGHT_PLATFORM": "windows", "CHEMENU_PREFLIGHT_LONGPATHS": "1"}
def _pwsh(machine: Machine, script: str, *args: str, stdin: str | None = None) -> subprocess.CompletedProcess:
env = {
"PATH": os.pathsep.join(str(d) for d in machine.path_dirs),
"HOME": str(machine.base),
"PIP_LOG": str(machine.pip_log),
"DOTNET_CLI_TELEMETRY_OPTOUT": "1",
"POWERSHELL_TELEMETRY_OPTOUT": "1",
**machine.extra_env,
}
return subprocess.run(
[PWSH, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(machine.tools / script), *args],
input=stdin, capture_output=True, text=True, env=env, timeout=120,
)
def _preflight(machine: Machine, *args: str) -> subprocess.CompletedProcess:
return _pwsh(machine, "preflight.ps1", *args)
@pytest.fixture
def machine(tmp_path: Path) -> Machine:
return Machine(tmp_path.resolve()).standard()
# --- the happy path ---------------------------------------------------------------
def test_complete_path_records_absolute_paths_and_sets_up_the_venv(machine):
result = _preflight(machine)
assert result.returncode == 0, result.stdout + result.stderr
data = machine.recorded()
assert data["schema"] == 1 and data["complete"] is True
assert set(data["tools"]) == {"python", "git", "rg"}
assert data["tools"]["rg"] == str(machine.stubs / "rg")
assert (machine.tools / ".venv" / "bin" / "python").is_file()
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
assert "Preflight passed" in result.stdout
def test_second_run_changes_nothing(machine):
assert _preflight(machine).returncode == 0
before = machine.tools_file.read_text(encoding="utf-8")
again = _preflight(machine)
assert again.returncode == 0, again.stdout
assert machine.tools_file.read_text(encoding="utf-8") == before
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
@pytest.mark.skipif(not SHELLS, reason="no POSIX shell on this machine")
def test_both_preflights_record_the_same_file(machine):
assert _preflight(machine).returncode == 0
from_pwsh = machine.tools_file.read_bytes()
machine.tools_file.unlink()
assert machine.run(shell=SHELLS[0]).returncode == 0
assert machine.tools_file.read_bytes() == from_pwsh
@pytest.mark.skipif(not SHELLS, reason="no POSIX shell on this machine")
def test_both_preflights_record_an_optional_op_the_same_way(machine):
machine.stub("op", "#!/bin/sh\necho '2.30.0'\n")
assert _preflight(machine).returncode == 0
from_pwsh = machine.tools_file.read_bytes()
assert json.loads(from_pwsh)["tools"]["op"] == str(machine.stubs / "op")
machine.tools_file.unlink()
assert machine.run(shell=SHELLS[0]).returncode == 0
assert machine.tools_file.read_bytes() == from_pwsh
def test_an_absent_optional_op_is_no_problem(machine):
result = _preflight(machine)
assert result.returncode == 0, result.stdout + result.stderr
assert "op" not in machine.recorded()["tools"]
assert any(line.split()[:2] == ["ABSENT", "op"] for line in result.stdout.splitlines())
def test_help_prints_the_usage_and_exits_0(machine):
result = _preflight(machine, "--help")
assert result.returncode == 0
assert "--set" in result.stdout
assert not machine.tools_file.exists()
# --- stop cases -------------------------------------------------------------------
def test_missing_rg_stops_with_42_and_a_missing_line(tmp_path):
machine = Machine(tmp_path.resolve()).standard(rg=False)
result = _preflight(machine)
assert result.returncode == 42
assert any(line.split()[:2] == ["MISSING", "rg"] for line in result.stdout.splitlines())
assert_guidance(result.stdout, "ripgrep (rg) was not found", "--set rg=")
data = machine.recorded()
assert data["complete"] is False and "rg" not in data["tools"]
assert not (machine.tools / ".venv").exists()
def test_python_too_old_stops(machine):
machine.extra_env["FAKE_PY_VERSION"] = "3.9"
result = _preflight(machine)
assert result.returncode == 42
assert "TOO_OLD" in result.stdout
assert_guidance(result.stdout, "Python 3.9 is too old")
def test_invalid_set_path_writes_nothing(machine):
result = _preflight(machine, "--set", f"rg={machine.base / 'nowhere' / 'rg'}")
assert result.returncode == 42
assert_guidance(result.stdout, "The path given for ripgrep (rg) does not work")
assert not machine.tools_file.exists()
def test_valid_set_path_is_recorded_and_kept(machine):
elsewhere = machine.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1'\n", machine.base / "opt")
machine.stub("rg", "#!/bin/sh\nexit 1\n")
result = _preflight(machine, f"--set=rg={elsewhere}")
assert result.returncode == 0, result.stdout
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
assert _preflight(machine).returncode == 0
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
def test_set_for_an_unknown_tool_is_a_usage_error(machine):
result = _preflight(machine, "--set", "foo=/bin/sh")
assert result.returncode == 1
assert not machine.tools_file.exists()
def test_venv_that_cannot_be_created_stops(machine):
machine.extra_env["FAKE_VENV_FAIL"] = "1"
result = _preflight(machine)
assert result.returncode == 42
assert_guidance(result.stdout, "ensurepip is not available")
def test_pip_failure_stops_and_is_retried_next_time(machine):
machine.extra_env["FAKE_PIP_FAIL"] = "1"
result = _preflight(machine)
assert result.returncode == 42
assert_guidance(result.stdout, "network unreachable")
assert machine.recorded()["complete"] is False
del machine.extra_env["FAKE_PIP_FAIL"]
assert _preflight(machine).returncode == 0
assert machine.recorded()["complete"] is True
def _browser_lines(machine: Machine) -> list[str]:
if not machine.pip_log.exists():
return []
return [line for line in machine.pip_log.read_text(encoding="utf-8").splitlines()
if "requirements-browser" in line or line.startswith("playwright ")]
def test_with_browser_installs_the_component_and_a_plain_run_keeps_it(machine):
(machine.tools / "requirements-browser.txt").write_text("playwright\n", encoding="utf-8")
assert _preflight(machine).returncode == 0
assert _browser_lines(machine) == []
result = _preflight(machine, "--with", "browser")
assert result.returncode == 0, result.stdout + result.stderr
venv = machine.tools / ".venv"
assert f"playwright install chromium into {venv / 'ms-playwright'}" in _browser_lines(machine)
assert "OK browser" in result.stdout
assert _preflight(machine).returncode == 0
assert len(_browser_lines(machine)) == 2
@pytest.mark.parametrize("failing", ["FAKE_BROWSER_PIP_FAIL", "FAKE_PLAYWRIGHT_FAIL"])
def test_a_component_that_fails_stops_but_leaves_wikitool_working(machine, failing):
(machine.tools / "requirements-browser.txt").write_text("playwright\n", encoding="utf-8")
machine.extra_env[failing] = "1"
result = _preflight(machine, "--with=browser")
assert result.returncode == 42
assert_guidance(result.stdout, "optional browser component", "--with browser")
assert machine.recorded()["complete"] is True
def test_an_unknown_component_is_refused(machine):
result = _preflight(machine, "--with", "spreadsheet")
assert result.returncode == 1
assert "not an optional component" in result.stderr
@pytest.mark.parametrize("platform, alias", [("linux", "python3"), ("windows", "python")])
def test_store_alias_is_never_run_and_never_recorded(machine, platform, alias):
marker = machine.base / "alias-was-run"
apps = machine.base / "Users" / "u" / "AppData" / "Local" / "Microsoft" / "WindowsApps"
machine.stub(alias, f"#!/bin/sh\necho ran > '{marker}'\nexit 9009\n", apps)
machine.path_dirs.insert(0, apps)
machine.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": platform, "CHEMENU_PREFLIGHT_LONGPATHS": "1"})
if platform == "windows":
machine.python("python")
result = _preflight(machine)
assert result.returncode == 0, result.stdout
recorded = machine.recorded()["tools"]["python"]
assert "WindowsApps" not in recorded
assert recorded.startswith(str(machine.stubs))
assert not marker.exists()
# --- install folder length (D32) --------------------------------------------------
@pytest.mark.parametrize("length, longpaths, expected", [
(95, "0", 0),
(96, "0", 42),
(96, "1", 0),
])
def test_install_folder_limit_at_the_boundary(tmp_path, length, longpaths, expected):
machine = _machine_with_root_of(tmp_path, length)
machine.extra_env["CHEMENU_PREFLIGHT_LONGPATHS"] = longpaths
result = _preflight(machine)
assert result.returncode == expected, result.stdout
if expected == 42:
assert_guidance(result.stdout, f"too long ({length} characters, at most 95)", "C:\\Chemenu")
assert not (machine.tools / ".venv").exists()
# --- asset mode (D33, D38-D41) ------------------------------------------------------
#
# The sh tests in test_preflight.py cover the same cases against preflight.sh; what is
# here is the twin's own wiring - Get-FileHash, tar.exe, Invoke-WebRequest - and the
# hand-over to the tree copy in a child pwsh.
def _asset_run(asset: AssetMachine, *args: str) -> subprocess.CompletedProcess:
env = {
"PATH": os.pathsep.join(str(d) for d in asset.path_dirs),
"HOME": str(asset.base),
"PIP_LOG": str(asset.pip_log),
"DOTNET_CLI_TELEMETRY_OPTOUT": "1",
"POWERSHELL_TELEMETRY_OPTOUT": "1",
**asset.extra_env,
}
return subprocess.run(
[PWSH, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(asset.script), *args],
capture_output=True, text=True, env=env, timeout=120, cwd=asset.cwd,
)
@pytest.fixture
def asset(tmp_path: Path) -> AssetMachine:
return AssetMachine(tmp_path.resolve(), script="preflight.ps1")
def test_asset_unpacks_into_its_own_folder_and_runs_the_tree_copy(asset):
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 0, result.stdout + result.stderr
assert "sha256 OK" in result.stdout and "Preflight passed" in result.stdout
assert (asset.tools / "preflight.ps1").is_file()
assert asset.recorded()["complete"] is True
assert not asset.script.exists()
assert not asset.leftovers()
def test_an_empty_clone_is_an_empty_folder(asset):
(asset.root / ".git").mkdir()
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 0, result.stdout + result.stderr
assert (asset.root / ".git").is_dir() and (asset.tools / "preflight.ps1").is_file()
def test_into_chooses_the_target_and_a_relative_one_follows_the_working_directory(asset):
far = asset.base / "deep" / "er" / "wiki"
assert _asset_run(asset, "--archive", str(asset.tarball), "--into", str(far)).returncode == 0
assert (far / "tools" / "preflight.ps1").is_file() and not asset.unpacked()
assert asset.script.exists()
asset.cwd = asset.base
assert _asset_run(asset, "--archive", str(asset.tarball), "--into", "here").returncode == 0
assert (asset.base / "here" / "tools" / "preflight.ps1").is_file()
def test_an_occupied_target_is_refused_and_left_alone(asset):
(asset.root / "mine.txt").write_text("keep", encoding="utf-8")
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 1
assert "is not empty" in result.stderr and "--into" in result.stderr
assert sorted(path.name for path in asset.root.iterdir()) == ["mine.txt", "preflight.ps1"]
def test_a_wrong_sha256_exits_1_and_unpacks_nothing(asset):
checksum = asset.tarball.parent / f"{asset.tarball.name}.sha256"
checksum.write_text("0" * 64 + f" {asset.tarball.name}\n", encoding="utf-8")
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 1
assert "does not match" in result.stderr and "nothing was unpacked" in result.stderr
assert not asset.unpacked() and not asset.leftovers()
def test_archive_without_a_checksum_file_exits_1(asset):
(asset.tarball.parent / f"{asset.tarball.name}.sha256").unlink()
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 1 and ".sha256 is missing" in result.stderr
assert not asset.unpacked()
def test_an_archive_with_two_top_level_folders_exits_1(tmp_path):
asset = AssetMachine(tmp_path.resolve(), script="preflight.ps1", tops=("chemenu-stack-9.9.9", "stray"))
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 1 and "exactly one top-level folder" in result.stderr
assert not asset.unpacked() and not asset.leftovers()
def test_a_copy_without_download_addresses_says_it_is_no_release_asset(asset):
result = _asset_run(asset)
assert result.returncode == 1 and "does not come from a release" in result.stderr
assert not asset.unpacked()
def test_a_missing_tar_is_a_stop_with_guidance(asset):
(asset.sysbin / "tar").unlink()
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 42
assert_guidance(result.stdout, "(tar) could not be found")
assert not asset.unpacked()
def test_set_and_the_exit_code_pass_through_to_the_tree_copy(asset):
refused = _asset_run(asset, "--archive", str(asset.tarball), "--set", f"rg={asset.base / 'nowhere' / 'rg'}")
assert refused.returncode == 42
assert_guidance(refused.stdout, "The path given for ripgrep (rg) does not work")
assert (asset.tools / "preflight.ps1").is_file() and not asset.tools_file.exists()
second = AssetMachine(asset.base / "second", script="preflight.ps1")
elsewhere = second.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1'\n", asset.base / "opt")
ok = _asset_run(second, "--archive", str(second.tarball), "--set", f"rg={elsewhere}")
assert ok.returncode == 0, ok.stdout
assert second.recorded()["tools"]["rg"] == str(elsewhere)
@pytest.mark.parametrize("partial", ["", ":partial"])
def test_a_move_that_fails_twice_is_retried_into_a_whole_entry(asset, partial):
asset.extra_env["CHEMENU_PREFLIGHT_MOVE_FAIL"] = f"tools:2{partial}"
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 0, result.stdout + result.stderr
assert "Preflight passed" in result.stdout
assert tools_as_unpacked(asset.root) == ARCHIVED_TOOLS
assert not asset.leftovers()
@pytest.mark.parametrize("partial", ["", ":partial"])
@pytest.mark.parametrize("into", [False, True])
def test_a_move_that_never_works_is_taken_back(asset, partial, into):
target = asset.base / "wiki" if into else asset.root
(target / ".git").mkdir(parents=True)
(target / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
before = folder_state(target)
asset.extra_env["CHEMENU_PREFLIGHT_MOVE_FAIL"] = f"tools:always{partial}"
args = ("--archive", str(asset.tarball), *(("--into", str(target)) if into else ()))
started = time.monotonic()
result = _asset_run(asset, *args)
assert result.returncode == 1, result.stdout + result.stderr
# Eight attempts, and none of the 30 seconds of pauses between them under the hook.
assert time.monotonic() - started < 20
assert "could not move tools into" in result.stderr
assert f"is denied. (CHEMENU_PREFLIGHT_MOVE_FAIL, attempt {MOVE_ATTEMPTS})" in result.stderr
assert "nothing was installed" in result.stderr
assert folder_state(target) == before
assert not asset.leftovers()
del asset.extra_env["CHEMENU_PREFLIGHT_MOVE_FAIL"]
again = _asset_run(asset, *args)
assert again.returncode == 0, again.stdout + again.stderr
assert tools_as_unpacked(target) == ARCHIVED_TOOLS
def test_an_entry_that_moved_before_the_failure_is_taken_back_too(asset):
add_to_release(asset, MOVED_FIRST)
asset.extra_env["CHEMENU_PREFLIGHT_MOVE_FAIL"] = "tools:always"
result = _asset_run(asset, "--archive", str(asset.tarball))
assert result.returncode == 1, result.stdout + result.stderr
assert "nothing was installed" in result.stderr
assert sorted(path.name for path in asset.root.iterdir()) == [asset.script.name]
assert not asset.leftovers()
@pytest.mark.parametrize("flag", ["--into", "--archive"])
def test_asset_options_are_a_usage_error_inside_a_tree(machine, flag):
result = _preflight(machine, flag, str(machine.base))
assert result.returncode == 1 and "belong to the release asset" in result.stderr
@pytest.mark.parametrize("length, longpaths, limit, expected", [
(95, "0", None, 0),
(96, "0", None, 42),
(96, "1", None, 0),
(110, "0", 120, 0),
(121, "0", 120, 42),
])
def test_the_folder_limit_is_judged_at_the_final_target_from_the_archive(
tmp_path, length, longpaths, limit, expected):
base = tmp_path.resolve()
name_length = length - len(str(base / "download")) - 1
assert name_length > 0, "tmp_path is too long for this test"
asset = AssetMachine(base, script="preflight.ps1", limit=limit)
asset.standard(pwsh=True)
asset.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": "windows", "CHEMENU_PREFLIGHT_LONGPATHS": longpaths})
target = asset.download / ("t" * name_length)
result = _asset_run(asset, "--archive", str(asset.tarball), "--into", str(target))
assert result.returncode == expected, result.stdout + result.stderr
if expected == 42:
assert_guidance(result.stdout, f"too long ({length} characters, at most {limit or 95})",
"such as C:\\Chemenu - put this script there", "--into C:\\Chemenu.")
assert not asset.unpacked(target) and not asset.leftovers()
else:
assert (target / "tools" / "preflight.ps1").is_file()
@pytest.fixture
def release_server(asset):
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(asset.tarball.parent))
handler.log_message = lambda *args: None
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
script = asset.script
script.write_text(
(TOOLS / "preflight.ps1").read_text(encoding="utf-8").replace(
"$ReleaseArchiveUrl = \'\'", f"$ReleaseArchiveUrl = \'http://127.0.0.1:{server.server_port}/{asset.tarball.name}\'").replace(
"$ReleaseChecksumUrl = \'\'", f"$ReleaseChecksumUrl = \'http://127.0.0.1:{server.server_port}/{asset.tarball.name}.sha256\'"),
encoding="utf-8")
yield asset
server.shutdown()
def test_download_fetches_both_files_and_unpacks(release_server):
result = _asset_run(release_server)
assert result.returncode == 0, result.stdout + result.stderr
assert "Downloading http://127.0.0.1" in result.stdout and "sha256 OK" in result.stdout
assert (release_server.tools / "preflight.ps1").is_file()
assert not release_server.leftovers()
def test_a_failed_download_exits_1_and_unpacks_nothing(release_server):
(release_server.tarball.parent / f"{release_server.tarball.name}.sha256").unlink()
result = _asset_run(release_server)
assert result.returncode == 1 and "the download failed" in result.stderr
assert not release_server.unpacked() and not release_server.leftovers()
# --- execution policy and Mark of the Web (D16, T6) --------------------------------
OPEN = "MachinePolicy=Undefined,UserPolicy=Undefined,Process=Undefined,CurrentUser=Undefined,LocalMachine=RemoteSigned"
@pytest.mark.parametrize("policy", [
OPEN,
"CurrentUser=Unrestricted,LocalMachine=Restricted",
"Process=Restricted,LocalMachine=RemoteSigned",
"CurrentUser=Undefined,LocalMachine=Undefined",
])
def test_a_policy_that_lets_scripts_run_passes(machine, policy):
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_POLICY": policy})
result = _preflight(machine)
assert result.returncode == 0, result.stdout
@pytest.mark.parametrize("policy", [
"CurrentUser=Restricted,LocalMachine=RemoteSigned",
"CurrentUser=AllSigned",
"CurrentUser=Undefined,LocalMachine=Restricted",
"Process=Bypass,LocalMachine=AllSigned",
])
def test_a_policy_that_blocks_scripts_stops_with_the_one_line_fix(machine, policy):
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_POLICY": policy})
result = _preflight(machine)
assert result.returncode == 42
assert_guidance(result.stdout, "Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned")
assert not (machine.tools / ".venv").exists()
@pytest.mark.parametrize("scope", ["MachinePolicy", "UserPolicy"])
def test_a_group_policy_is_a_stop_that_names_the_administrator(machine, scope):
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_POLICY": f"{scope}=AllSigned,LocalMachine=RemoteSigned"})
result = _preflight(machine)
assert result.returncode == 42
assert_guidance(result.stdout, "group policy", "Git Bash")
assert "Set-ExecutionPolicy" not in result.stdout
def test_policy_and_marks_are_not_checked_off_windows(machine):
machine.extra_env.update({
"CHEMENU_PREFLIGHT_PLATFORM": "linux",
"CHEMENU_PREFLIGHT_POLICY": "CurrentUser=AllSigned",
"CHEMENU_PREFLIGHT_MARKED": "preflight.ps1",
})
assert _preflight(machine).returncode == 0
def test_a_marked_script_stops_with_the_unblock_command(machine):
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_MARKED": "wikitool.ps1,preflight.ps1"})
result = _preflight(machine)
assert result.returncode == 42
assert_guidance(result.stdout, "wikitool.ps1", "Unblock-File")
assert not (machine.tools / ".venv").exists()
# --- the launcher -----------------------------------------------------------------
def _launch(machine: Machine, *args: str) -> subprocess.CompletedProcess:
return _pwsh(machine, "wikitool.ps1", *args)
def _complete_file(machine: Machine, complete: bool = True) -> None:
machine.tools_file.write_text(json.dumps(
{"schema": 1, "complete": complete, "tools": {"git": "/usr/bin/git"}}, indent=2),
encoding="utf-8")
@pytest.mark.parametrize("state", ["no file", "no venv", "incomplete"])
def test_launcher_stops_with_42_until_the_preflight_has_passed(machine, state):
if state != "no file":
_complete_file(machine, complete=(state != "incomplete"))
if state != "no venv":
machine.stub("python", ECHO_PYTHON, machine.tools / ".venv" / "bin")
result = _launch(machine, "doctor")
assert result.returncode == 42
assert "tools/preflight.ps1" in result.stderr
assert "ExecutionPolicy Bypass" in result.stderr
@pytest.mark.parametrize("layout", [("bin", "python"), ("Scripts", "python.exe")])
def test_launcher_runs_the_entry_script_with_either_venv_layout(machine, layout):
_complete_file(machine)
machine.stub(layout[1], ECHO_PYTHON, machine.tools / ".venv" / layout[0])
result = _launch(machine, "doctor", "--json")
assert result.returncode == 0, result.stderr
assert result.stdout.splitlines() == [str(machine.tools / "run_wikitool.py"), "doctor", "--json"]
def test_launcher_passes_the_exit_code_of_the_cli_through(machine):
_complete_file(machine)
machine.stub("python", "#!/bin/sh\nexit 7\n", machine.tools / ".venv" / "bin")
assert _launch(machine, "lint").returncode == 7
def test_both_launchers_exist_for_pwsh_to_resolve():
assert (TOOLS / "wikitool.ps1").is_file() and (TOOLS / "wikitool").is_file()
# --- trace-hook.ps1 -----------------------------------------------------------------
# Prints its arguments, then whatever arrived on stdin - with shell built-ins only, since the
# stub machine's PATH carries no `cat` of its own.
ECHO_STDIN_PYTHON = ECHO_PYTHON + "while IFS= read -r line; do printf '%s\\n' \"$line\"; done\n"
PAYLOAD = '{"sessionId": "s-1", "toolName": "bash"}'
def _hook(machine: Machine, *args: str) -> subprocess.CompletedProcess:
return _pwsh(machine, "trace-hook.ps1", *args, stdin=PAYLOAD + "\n")
@pytest.mark.parametrize("layout", [("Scripts", "python.exe"), ("bin", "python")])
def test_trace_hook_ps1_hands_arguments_and_payload_to_the_venv_python(machine, layout):
machine.stub(layout[1], ECHO_STDIN_PYTHON, machine.tools / ".venv" / layout[0])
result = _hook(machine, "--source", "copilot-cli", "--event", "tool.pre")
assert result.returncode == 0, result.stderr
assert result.stdout.splitlines() == [
str(machine.tools / "trace_ingest.py"), "--source", "copilot-cli", "--event", "tool.pre", PAYLOAD,
]
def test_trace_hook_ps1_is_silent_without_a_venv(machine):
result = _hook(machine, "--source", "claude-code", "--event", "prompt.submitted")
assert result.returncode == 0
assert result.stdout == "" and result.stderr == ""
def test_trace_hook_ps1_never_fails_the_call_it_observes(machine):
"""Copilot denies the tool call on a non-zero `preToolUse` hook - an observer that
passed a failing Python through would turn into a blocker."""
machine.stub("python", "#!/bin/sh\necho 'Traceback: broken' >&2\nexit 2\n", machine.tools / ".venv" / "bin")
result = _hook(machine, "--source", "copilot-cli", "--event", "tool.pre")
assert result.returncode == 0
assert result.stderr == ""
def test_the_python_side_reads_what_the_hooks_say(monkeypatch):
"""The doctor checks use the same hook values the script does; the two
parsers must agree on what a policy list means."""
monkeypatch.setenv(prerequisites.ENV_POLICY, "MachinePolicy=AllSigned,LocalMachine=RemoteSigned")
policy = prerequisites.execution_policy()
assert policy is not None and policy.blocks and policy.group_policy