feat: PowerShell 7 preflight and launcher - preflight.ps1, wikitool.ps1, doctor policy and Mark of the Web checks, pwsh CI job (#151, B)
Files changed: - .gitea/workflows/ci.yml - CHANGES.md - INSTALL.md - README.md - VERSION - docs/why-gates-are-code.md - instructions/bootstrap.md - instructions/bug-report.md - instructions/preflight.md - instructions/setup-instance.md - instructions/upgrade-instance.md - tools/CONTRACT.md - tools/README.md - tools/bugreport.py - tools/chemenu/cli.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/doctor.py - tools/chemenu/prerequisites.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_bugreport.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_preflight.py - tools/chemenu/tests/test_preflight_pwsh.py - tools/chemenu/toolpaths.py - tools/preflight.ps1 - tools/wikitool - tools/wikitool.ps1
This commit is contained in:
1 parent
4035b1ba12
commit
210e0c8286
27 files changed
+1513
-32
No files matched your search
@@ -63,6 +63,8 @@ except ModuleNotFoundError as exc:
|
||||
"This is not optional - schema validation depends on it. Run the preflight,\n"
|
||||
"which (re)installs tools/.venv from tools/requirements.txt:\n"
|
||||
" tools/preflight.sh\n"
|
||||
"or, from PowerShell 7:\n"
|
||||
f" {toolpaths.PREFLIGHT_PWSH}\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -1516,7 +1516,7 @@ def run_upgrade(
|
||||
summary += (
|
||||
" Nothing was committed and nothing is verified yet."
|
||||
" `instructions/upgrade-instance.md` carries the order for everything that follows"
|
||||
" and resumes with the preflight (`tools/preflight.sh`), which `tools/wikitool` now"
|
||||
" and resumes with the preflight (`tools/preflight.sh`, or `tools/preflight.ps1` under PowerShell 7), which `tools/wikitool` now"
|
||||
" refuses to run without."
|
||||
)
|
||||
success(summary)
|
||||
@@ -49,7 +49,7 @@ def _git(args: list[str]) -> Optional[subprocess.CompletedProcess]:
|
||||
return None
|
||||
|
||||
|
||||
PREFLIGHT_FIX = "Run tools/preflight.sh"
|
||||
PREFLIGHT_FIX = f"Run tools/preflight.sh (PowerShell 7: {toolpaths.PREFLIGHT_PWSH})"
|
||||
|
||||
|
||||
def check_python() -> Check:
|
||||
@@ -130,6 +130,53 @@ def check_install_dir() -> Check:
|
||||
)
|
||||
|
||||
|
||||
def check_execution_policy() -> Check:
|
||||
"""Whether an ordinary PowerShell 7 may run `tools/wikitool.ps1` - the policy
|
||||
the preflight checks before it stops, so a harness that starts `pwsh`
|
||||
without a bypass is not turned away by a setting nobody looked at."""
|
||||
if prerequisites.platform() != "windows":
|
||||
return Check("execution-policy", "OK", "only PowerShell on Windows has one - not applicable here")
|
||||
policy = prerequisites.execution_policy()
|
||||
if policy is None:
|
||||
return Check(
|
||||
"execution-policy", "WARN", "the PowerShell execution policy could not be read",
|
||||
f"{PREFLIGHT_FIX}; the preflight checks it",
|
||||
)
|
||||
if not policy.blocks:
|
||||
return Check("execution-policy", "OK", policy.describe())
|
||||
if policy.group_policy:
|
||||
return Check(
|
||||
"execution-policy", "FAIL",
|
||||
f"a group policy sets the PowerShell execution policy to {policy.policy} - "
|
||||
"tools/wikitool.ps1 does not start",
|
||||
"A group policy cannot be overridden from this computer: ask whoever looks after it to allow "
|
||||
"locally written scripts (RemoteSigned) for PowerShell 7, or run `tools/wikitool` from Git Bash",
|
||||
)
|
||||
return Check(
|
||||
"execution-policy", "FAIL",
|
||||
f"the PowerShell execution policy is {policy.describe()} - tools/wikitool.ps1 does not start",
|
||||
"In a PowerShell 7 window: Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned",
|
||||
)
|
||||
|
||||
|
||||
def check_script_marks() -> Check:
|
||||
"""Mark of the Web on the stack's PowerShell scripts: a wiki downloaded with a
|
||||
browser and unpacked in Explorer carries one, and PowerShell refuses to run
|
||||
a marked script under its usual policy."""
|
||||
if prerequisites.platform() != "windows":
|
||||
return Check("script-marks", "OK", "no Mark of the Web outside Windows")
|
||||
marked = prerequisites.marked_scripts()
|
||||
if not marked:
|
||||
return Check("script-marks", "OK", "no script under tools/ is marked as downloaded")
|
||||
shown = ", ".join(marked[:5]) + (", ..." if len(marked) > 5 else "")
|
||||
return Check(
|
||||
"script-marks", "FAIL",
|
||||
f"Windows marks scripts under tools/ as downloaded from the internet: {shown}",
|
||||
"In a PowerShell 7 window, from the folder holding tools/: "
|
||||
"Get-ChildItem -Recurse -File | Unblock-File",
|
||||
)
|
||||
|
||||
|
||||
def check_author() -> Check:
|
||||
try:
|
||||
author = config.default_author()
|
||||
@@ -669,6 +716,8 @@ def run_doctor() -> list[Check]:
|
||||
check_tool_paths(),
|
||||
check_ripgrep(),
|
||||
check_install_dir(),
|
||||
check_execution_policy(),
|
||||
check_script_marks(),
|
||||
check_author(),
|
||||
check_stack_version(),
|
||||
check_kb_version(),
|
||||
@@ -706,10 +755,16 @@ def run_doctor() -> list[Check]:
|
||||
"Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that "
|
||||
"finished, every tool `tools/prerequisites.txt` names for this platform recorded, and "
|
||||
"every recorded path still there - a `FAIL` otherwise, fixed by running "
|
||||
"`tools/preflight.sh` again.",
|
||||
"`tools/preflight.sh` again (PowerShell 7: `tools/preflight.ps1`).",
|
||||
"Install folder (`install-dir`): on Windows with long paths off, a `FAIL` when the "
|
||||
"folder holding `tools/` is longer than `tools/prerequisites.txt` allows (95 "
|
||||
"characters) - the limit the preflight enforces before it sets anything up.",
|
||||
"PowerShell (`execution-policy`, `script-marks`; Windows only, `OK` elsewhere): a `FAIL` "
|
||||
"when the effective execution policy is `Restricted` or `AllSigned` - the line then says whether "
|
||||
"a group policy sets it, which only whoever administers the computer can change - and a "
|
||||
"`FAIL` when a script under `tools/` carries a Mark of the Web from the internet zone, "
|
||||
"which a browser download unpacked in Explorer leaves behind and `Invoke-WebRequest` plus "
|
||||
"`tar` do not. `tools/preflight.ps1` checks the same two things before it stops.",
|
||||
"Personalization: `USER.md`/`SOUL.md` present **and** filled - a file still carrying "
|
||||
"the template's sentinel is a `FAIL`.",
|
||||
"KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three "
|
||||
|
||||
@@ -8,21 +8,34 @@ preflight enforced up front - a tool whose recorded path has gone, a checkout
|
||||
moved into a folder too long for Windows.
|
||||
|
||||
`CHEMENU_PREFLIGHT_PLATFORM` and `CHEMENU_PREFLIGHT_LONGPATHS` stand in for the
|
||||
real platform and registry, exactly as they do for the preflight scripts; the
|
||||
test suite is their only user.
|
||||
real platform and registry, exactly as they do for the preflight scripts;
|
||||
`CHEMENU_PREFLIGHT_POLICY` and `CHEMENU_PREFLIGHT_MARKED` stand in for the
|
||||
PowerShell execution policy and the Mark of the Web on the stack's scripts the
|
||||
same way (what `tools/preflight.ps1` documents). The test suite is their only
|
||||
user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from chemenu import config, titles
|
||||
from chemenu import config, titles, toolpaths
|
||||
|
||||
ENV_PLATFORM = "CHEMENU_PREFLIGHT_PLATFORM"
|
||||
ENV_LONGPATHS = "CHEMENU_PREFLIGHT_LONGPATHS"
|
||||
ENV_POLICY = "CHEMENU_PREFLIGHT_POLICY"
|
||||
ENV_MARKED = "CHEMENU_PREFLIGHT_MARKED"
|
||||
|
||||
# Which scopes decide whether an ordinary pwsh starts tools/wikitool.ps1, strongest first.
|
||||
# `Process` is left out: it holds a bypass the caller started with, not the machine's setting.
|
||||
POLICY_SCOPES = ("MachinePolicy", "UserPolicy", "CurrentUser", "LocalMachine")
|
||||
GROUP_POLICY_SCOPES = ("MachinePolicy", "UserPolicy")
|
||||
BLOCKING_POLICIES = ("Restricted", "AllSigned")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -115,3 +128,86 @@ def install_dir_problem(folder: Optional[str] = None) -> Optional[str]:
|
||||
f"the install folder is {length} characters long and Windows allows at most "
|
||||
f"{limit} here (long paths are off): {folder}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Policy:
|
||||
"""The execution policy an ordinary pwsh applies. `scope` is None when no
|
||||
scope sets one, which on Windows means the default, RemoteSigned."""
|
||||
|
||||
scope: Optional[str]
|
||||
policy: str
|
||||
|
||||
@property
|
||||
def blocks(self) -> bool:
|
||||
return self.policy in BLOCKING_POLICIES
|
||||
|
||||
@property
|
||||
def group_policy(self) -> bool:
|
||||
return self.scope in GROUP_POLICY_SCOPES
|
||||
|
||||
def describe(self) -> str:
|
||||
if self.scope is None:
|
||||
return f"{self.policy} (the default)"
|
||||
return f"{self.policy} (set for {self.scope})"
|
||||
|
||||
|
||||
def _parse_policy_list(text: str) -> Optional[Policy]:
|
||||
"""`Scope=Policy` pairs, separated by commas or lines, to the effective policy."""
|
||||
pairs = {}
|
||||
for item in re.split(r"[,\r\n]+", text):
|
||||
scope, sep, policy = item.partition("=")
|
||||
if sep:
|
||||
pairs[scope.strip()] = policy.strip()
|
||||
if not pairs:
|
||||
return None
|
||||
for scope in POLICY_SCOPES:
|
||||
policy = pairs.get(scope, "Undefined")
|
||||
if policy and policy != "Undefined":
|
||||
return Policy(scope, policy)
|
||||
return Policy(None, "RemoteSigned")
|
||||
|
||||
|
||||
def execution_policy() -> Optional[Policy]:
|
||||
"""The effective PowerShell execution policy, or None when it cannot be
|
||||
read here (not Windows, or no pwsh to ask)."""
|
||||
forced = os.environ.get(ENV_POLICY, "").strip()
|
||||
if forced:
|
||||
return _parse_policy_list(forced)
|
||||
if platform() != "windows" or sys.platform != "win32":
|
||||
return None
|
||||
try: # pragma: no cover - Windows only
|
||||
pwsh = toolpaths.resolve("pwsh")
|
||||
done = subprocess.run(
|
||||
[pwsh, "-NoProfile", "-NonInteractive", "-Command",
|
||||
"Get-ExecutionPolicy -List | ForEach-Object { '{0}={1}' -f $_.Scope, $_.ExecutionPolicy }"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
except (toolpaths.ToolPathError, OSError, subprocess.SubprocessError): # pragma: no cover
|
||||
return None
|
||||
if done.returncode != 0: # pragma: no cover - Windows only
|
||||
return None
|
||||
return _parse_policy_list(done.stdout) # pragma: no cover - Windows only
|
||||
|
||||
|
||||
def marked_scripts() -> list[str]:
|
||||
"""PowerShell scripts below `tools/` that carry a Mark of the Web from the
|
||||
internet zone (3 or 4), as names relative to `tools/`. A browser download
|
||||
unpacked in Explorer has them; `Invoke-WebRequest` and `tar` do not."""
|
||||
forced = os.environ.get(ENV_MARKED, "").strip()
|
||||
if forced:
|
||||
return [name.strip() for name in forced.split(",") if name.strip()]
|
||||
if sys.platform != "win32":
|
||||
return []
|
||||
tools_dir = config._PACKAGE_ROOT / "tools" # pragma: no cover - Windows only
|
||||
marked = [] # pragma: no cover - Windows only
|
||||
for script in sorted(tools_dir.rglob("*.ps1")): # pragma: no cover - Windows only
|
||||
if ".venv" in script.relative_to(tools_dir).parts:
|
||||
continue
|
||||
try:
|
||||
stream = Path(str(script) + ":Zone.Identifier").read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
if re.search(r"ZoneId=[3-9]", stream):
|
||||
marked.append(script.relative_to(tools_dir).as_posix())
|
||||
return marked # pragma: no cover - Windows only
|
||||
@@ -35,6 +35,8 @@ _WIKITOOL_ENV = (
|
||||
"WIKITOOL_TASKS_CONFIG",
|
||||
"CHEMENU_PREFLIGHT_PLATFORM",
|
||||
"CHEMENU_PREFLIGHT_LONGPATHS",
|
||||
"CHEMENU_PREFLIGHT_POLICY",
|
||||
"CHEMENU_PREFLIGHT_MARKED",
|
||||
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
|
||||
|
||||
# Environment git reads for identity or for where its repo lives. A stray
|
||||
|
||||
@@ -61,7 +61,7 @@ def checkout(tmp_path: Path, monkeypatch) -> Path:
|
||||
json.dumps({"schema": 1, "api_token": TOKEN, "enabled": True})
|
||||
)
|
||||
(root / ".wikitool-tools.json").write_text(
|
||||
json.dumps({"schema": 1, "tools": {"python": {"path": "/definitely/not/here/python"}}})
|
||||
json.dumps({"schema": 1, "complete": True, "tools": {"python": "/definitely/not/here/python"}})
|
||||
)
|
||||
launcher = root / "tools" / "wikitool"
|
||||
launcher.write_text(LAUNCHER.format(python=sys.executable, title=TITLE))
|
||||
@@ -271,8 +271,9 @@ def test_the_tools_config_path_check_reports_what_is_missing(checkout, tmp_path)
|
||||
bundle = collect(checkout, tmp_path, "--no-trace")
|
||||
config = json.loads((bundle / "environment.json").read_text())["tools_config"]
|
||||
assert config["status"] == "present"
|
||||
assert config["content"]["complete"] is True
|
||||
assert config["path_checks"] == [
|
||||
{"at": "tools.python.path", "path": "/definitely/not/here/python", "exists": False}
|
||||
{"at": "tools.python", "path": "/definitely/not/here/python", "exists": False}
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ def _detail(checks, name) -> str:
|
||||
return next(c.detail for c in checks if c.name == name)
|
||||
|
||||
|
||||
def _fix(checks, name) -> str:
|
||||
return next(c.fix or "" for c in checks if c.name == name)
|
||||
|
||||
|
||||
def test_healthy_instance_has_no_fail(instance):
|
||||
checks = doctor.run_doctor()
|
||||
assert not any(c.status == "FAIL" for c in checks)
|
||||
@@ -640,3 +644,70 @@ def test_install_dir_is_not_limited_off_windows(instance, monkeypatch):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "linux")
|
||||
monkeypatch.setattr(prerequisites, "install_dir", lambda: "/" + "x" * 300)
|
||||
assert _status(doctor.run_doctor(), "install-dir") == "OK"
|
||||
|
||||
|
||||
# --- PowerShell: execution policy and Mark of the Web (Gitea #151) -----------------
|
||||
|
||||
OPEN_POLICY = "MachinePolicy=Undefined,UserPolicy=Undefined,Process=Undefined,CurrentUser=Undefined,LocalMachine=RemoteSigned"
|
||||
|
||||
|
||||
def _windows(monkeypatch, policy=None, marked=None):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "windows")
|
||||
if policy is not None:
|
||||
monkeypatch.setenv(prerequisites.ENV_POLICY, policy)
|
||||
if marked is not None:
|
||||
monkeypatch.setenv(prerequisites.ENV_MARKED, marked)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("check", ["execution-policy", "script-marks"])
|
||||
def test_powershell_checks_do_not_apply_off_windows(instance, monkeypatch, check):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "linux")
|
||||
monkeypatch.setenv(prerequisites.ENV_POLICY, "CurrentUser=AllSigned")
|
||||
monkeypatch.setenv(prerequisites.ENV_MARKED, "wikitool.ps1")
|
||||
assert _status(doctor.run_doctor(), check) == "OK"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy, expected", [
|
||||
(OPEN_POLICY, "OK"),
|
||||
("CurrentUser=Unrestricted,LocalMachine=Restricted", "OK"),
|
||||
("Process=Restricted,LocalMachine=RemoteSigned", "OK"),
|
||||
("CurrentUser=Undefined,LocalMachine=Undefined", "OK"),
|
||||
("CurrentUser=Restricted,LocalMachine=RemoteSigned", "FAIL"),
|
||||
("LocalMachine=AllSigned", "FAIL"),
|
||||
])
|
||||
def test_execution_policy_blocks_only_restricted_and_allsigned(instance, monkeypatch, policy, expected):
|
||||
_windows(monkeypatch, policy=policy)
|
||||
assert _status(doctor.run_doctor(), "execution-policy") == expected
|
||||
|
||||
|
||||
def test_execution_policy_fix_names_the_one_line_command(instance, monkeypatch):
|
||||
_windows(monkeypatch, policy="CurrentUser=Restricted")
|
||||
fix = _fix(doctor.run_doctor(), "execution-policy")
|
||||
assert "Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned" in fix
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", ["MachinePolicy", "UserPolicy"])
|
||||
def test_a_group_policy_gets_no_command_it_could_not_obey(instance, monkeypatch, scope):
|
||||
_windows(monkeypatch, policy=f"{scope}=AllSigned,LocalMachine=RemoteSigned")
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "execution-policy") == "FAIL"
|
||||
assert "Set-ExecutionPolicy" not in _fix(checks, "execution-policy")
|
||||
assert "Git Bash" in _fix(checks, "execution-policy")
|
||||
|
||||
|
||||
def test_execution_policy_that_cannot_be_read_is_a_warning(instance, monkeypatch):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "windows")
|
||||
assert _status(doctor.run_doctor(), "execution-policy") == "WARN"
|
||||
|
||||
|
||||
def test_marked_scripts_fail_with_the_unblock_command(instance, monkeypatch):
|
||||
_windows(monkeypatch, marked="wikitool.ps1,preflight.ps1")
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "script-marks") == "FAIL"
|
||||
assert "wikitool.ps1" in _detail(checks, "script-marks")
|
||||
assert "Unblock-File" in _fix(checks, "script-marks")
|
||||
|
||||
|
||||
def test_unmarked_scripts_are_ok(instance, monkeypatch):
|
||||
_windows(monkeypatch)
|
||||
assert _status(doctor.run_doctor(), "script-marks") == "OK"
|
||||
@@ -94,7 +94,8 @@ class Machine:
|
||||
self.root = base / root_name
|
||||
self.tools = self.root / "tools"
|
||||
self.tools.mkdir(parents=True)
|
||||
for name in ("preflight.sh", "prerequisites.txt", "wikitool", "run_wikitool.py", "trace-hook"):
|
||||
for name in ("preflight.sh", "preflight.ps1", "prerequisites.txt", "wikitool", "wikitool.ps1",
|
||||
"run_wikitool.py", "trace-hook"):
|
||||
shutil.copy2(TOOLS / name, self.tools / name)
|
||||
(self.tools / "requirements.txt").write_text("PyYAML\n", encoding="utf-8")
|
||||
self.sysbin = base / "sysbin"
|
||||
@@ -392,8 +393,11 @@ def test_launcher_runs_the_entry_script_with_either_venv_layout(machine, layout)
|
||||
assert result.stdout.splitlines() == [str(machine.tools / "run_wikitool.py"), "doctor", "--json"]
|
||||
|
||||
|
||||
def test_there_is_a_powershell_launcher_slot_but_no_cmd():
|
||||
def test_there_is_a_powershell_launcher_and_no_cmd():
|
||||
"""pwsh resolves `tools/wikitool` to `wikitool.ps1` before the sh launcher, and
|
||||
cmd.exe is not a supported shell, so there is no `.cmd`."""
|
||||
assert (TOOLS / "wikitool").is_file()
|
||||
assert (TOOLS / "wikitool.ps1").is_file()
|
||||
assert not (TOOLS / "wikitool.cmd").exists()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""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 json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import prerequisites
|
||||
from chemenu.tests.test_preflight import (
|
||||
ECHO_PYTHON, SHELLS, TOOLS, Machine, _machine_with_root_of, assert_guidance,
|
||||
)
|
||||
|
||||
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) -> 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],
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
# --- 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()
|
||||
|
||||
|
||||
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
|
||||
@@ -31,7 +31,8 @@ from chemenu.errors import ChemenuError
|
||||
|
||||
FILE_NAME = ".wikitool-tools.json"
|
||||
SCHEMA = 1
|
||||
PREFLIGHT = "run the preflight again: tools/preflight.sh"
|
||||
PREFLIGHT_PWSH = "pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1"
|
||||
PREFLIGHT = f"run the preflight again: tools/preflight.sh (PowerShell 7: {PREFLIGHT_PWSH})"
|
||||
|
||||
|
||||
class ToolPathError(ChemenuError):
|
||||
|
||||
Reference in new issue
Block a user