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
623 lines
27 KiB
Python
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
|