feat: preflight - prerequisites checked and tool paths recorded before wikitool runs; launcher refuses without it (#151, POSIX half)
Files changed: - .claude/settings.json - .gitea/workflows/ci.yml - .gitea/workflows/nightly.yml - .gitea/workflows/release.yml - .gitea/workflows/tracker-live.yml - .github/hooks/wiki-trace.json - .gitignore - .vibe/hooks.toml - AGENTS.md - CHANGES.md - EVALS.md - INSTALL.md - README.md - VERSION - instructions/bootstrap.md - instructions/preflight.md - instructions/setup-instance.md - instructions/upgrade-instance.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/cli.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/config.py - tools/chemenu/corpus_cache.py - tools/chemenu/prerequisites.py - tools/chemenu/search/ripgrep.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_dist_upgrade.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_preflight.py - tools/chemenu/toolpaths.py - tools/preflight.sh - tools/prerequisites.txt - tools/run_wikitool.py - tools/trace-hook - tools/wikitool
This commit is contained in:
1 parent
5a731729f6
commit
e4b2b6d9b1
40 files changed
+1849
-125
No files matched your search
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config, conventions
|
||||
from chemenu import config, conventions, toolpaths
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.session import HARNESS_ENV_VARS
|
||||
from chemenu.telemetry import policy as telemetry_policy
|
||||
@@ -33,6 +33,8 @@ _WIKITOOL_ENV = (
|
||||
"WIKITOOL_UPDATE_TOKEN",
|
||||
"CHEMENU_ROOT",
|
||||
"WIKITOOL_TASKS_CONFIG",
|
||||
"CHEMENU_PREFLIGHT_PLATFORM",
|
||||
"CHEMENU_PREFLIGHT_LONGPATHS",
|
||||
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
|
||||
|
||||
# Environment git reads for identity or for where its repo lives. A stray
|
||||
@@ -156,6 +158,12 @@ def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
for name in (*_WIKITOOL_ENV, *_GIT_ENV):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
# `.wikitool-tools.json` beside this checkout records where the developer's
|
||||
# git and rg live. A test must not depend on whether the preflight has run
|
||||
# here, so every test sees no file - the bare-name fallback - unless it
|
||||
# writes one of its own and points `toolpaths.tools_file` at it.
|
||||
monkeypatch.setattr(toolpaths, "tools_file", lambda: home / toolpaths.FILE_NAME)
|
||||
|
||||
# The same hole as the environment above, one layer in: `config` resolves
|
||||
# its paths on access, and `monkeypatch.setattr(config, "KB_DIR", ...)`
|
||||
# undoes itself by writing the *resolved* old path back as a real
|
||||
|
||||
@@ -345,7 +345,9 @@ def test_closing_report_points_at_the_upgrade_instruction(instance, tmp_path, ca
|
||||
|
||||
out = " ".join(capsys.readouterr().out.split()) # rich wraps; rejoin first
|
||||
assert "instructions/upgrade-instance.md" in out
|
||||
assert "instructions sync" in out
|
||||
# The first thing the new launcher needs (Gitea #151): it refuses to start
|
||||
# until the preflight has passed against the swapped-in tools/.
|
||||
assert "tools/preflight.sh" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("preserved", [".wikitool-kb.json", "CHANGES.md", "kb/log.md", "raw/notes/.gitkeep"])
|
||||
|
||||
@@ -4,12 +4,14 @@ is missing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config, conventions
|
||||
from chemenu import config, conventions, prerequisites, toolpaths
|
||||
from chemenu.commands import doctor, instructions_cmd
|
||||
|
||||
|
||||
@@ -17,6 +19,18 @@ def _git(root: Path, *args: str) -> None:
|
||||
subprocess.run(["git", *args], cwd=root, check=True, capture_output=True)
|
||||
|
||||
|
||||
def _write_tools_file(path: Path, tools: dict, complete: bool = True) -> Path:
|
||||
path.write_text(
|
||||
json.dumps({"schema": 1, "complete": complete, "tools": tools}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _real_tools() -> dict:
|
||||
return {"python": sys.executable, "git": shutil.which("git"), "rg": shutil.which("rg")}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""A minimal, fully-configured wiki instance: a git repo with identity,
|
||||
@@ -72,6 +86,11 @@ def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
_git(root, "config", "user.name", "Fixture Author")
|
||||
_git(root, "config", "user.email", "fixture@example.com")
|
||||
|
||||
# What the preflight would have written: every tool this platform needs,
|
||||
# at the path it really has on this machine.
|
||||
tools_file = _write_tools_file(root / toolpaths.FILE_NAME, _real_tools())
|
||||
monkeypatch.setattr(toolpaths, "tools_file", lambda: tools_file)
|
||||
|
||||
instructions_cmd.sync(force=False)
|
||||
return root
|
||||
|
||||
@@ -558,3 +577,66 @@ def test_tasks_provider_names_an_active_override(instance, monkeypatch):
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tasks-provider") == "OK"
|
||||
assert "WIKITOOL_TASKS_CONFIG" in _detail(checks, "tasks-provider")
|
||||
|
||||
|
||||
# --- tool paths and the install folder (Gitea #151) --------------------------
|
||||
|
||||
|
||||
def test_tool_paths_missing_file_is_a_fail_naming_the_preflight(instance):
|
||||
(instance / toolpaths.FILE_NAME).unlink()
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tool-paths") == "FAIL"
|
||||
fix = next(c.fix for c in checks if c.name == "tool-paths")
|
||||
assert "tools/preflight.sh" in fix
|
||||
|
||||
|
||||
def test_tool_paths_incomplete_run_is_a_fail(instance):
|
||||
_write_tools_file(instance / toolpaths.FILE_NAME, _real_tools(), complete=False)
|
||||
assert _status(doctor.run_doctor(), "tool-paths") == "FAIL"
|
||||
|
||||
|
||||
def test_tool_paths_vanished_path_is_a_fail(instance):
|
||||
tools = {**_real_tools(), "rg": str(instance / "gone" / "rg")}
|
||||
_write_tools_file(instance / toolpaths.FILE_NAME, tools)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tool-paths") == "FAIL"
|
||||
assert "no longer there: rg" in _detail(checks, "tool-paths")
|
||||
# The same broken path makes the ripgrep check fail instead of finding rg on PATH.
|
||||
assert _status(checks, "ripgrep") == "FAIL"
|
||||
|
||||
|
||||
def test_tool_paths_unrecorded_tool_is_a_fail(instance):
|
||||
tools = _real_tools()
|
||||
del tools["git"]
|
||||
_write_tools_file(instance / toolpaths.FILE_NAME, tools)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tool-paths") == "FAIL"
|
||||
assert "not recorded: git" in _detail(checks, "tool-paths")
|
||||
|
||||
|
||||
def test_tool_paths_complete_file_is_ok(instance):
|
||||
assert _status(doctor.run_doctor(), "tool-paths") == "OK"
|
||||
|
||||
|
||||
def _folder_of(length: int) -> str:
|
||||
return "C:\\" + "x" * (length - 3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length, longpaths, expected", [
|
||||
(95, "0", "OK"),
|
||||
(96, "0", "FAIL"),
|
||||
(96, "1", "OK"),
|
||||
])
|
||||
def test_install_dir_limit_at_the_boundary(instance, monkeypatch, length, longpaths, expected):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "windows")
|
||||
monkeypatch.setenv(prerequisites.ENV_LONGPATHS, longpaths)
|
||||
folder = _folder_of(length)
|
||||
assert len(folder) == length
|
||||
monkeypatch.setattr(prerequisites, "install_dir", lambda: folder)
|
||||
assert _status(doctor.run_doctor(), "install-dir") == expected
|
||||
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,529 @@
|
||||
"""tools/preflight.sh, the launcher, `tools/trace-hook` and `toolpaths` (Gitea #151).
|
||||
|
||||
The preflight runs before Python is known to exist, so it is tested as what it
|
||||
is: a POSIX shell script, run under every shell this machine has of dash and
|
||||
bash, against a `PATH` built for the test. That `PATH` holds two directories -
|
||||
the handful of utilities the script uses, symlinked one by one, and stubs for
|
||||
the tools under test - so whether `rg` or `python3` is "installed" is decided
|
||||
here and nowhere else. The stub Python fakes `-m venv` and `-m pip` as well, so
|
||||
no test creates a real venv or touches the network; the CI workflows run the
|
||||
preflight for real.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config, prerequisites, titles, toolpaths
|
||||
|
||||
TOOLS = config._PACKAGE_ROOT / "tools"
|
||||
|
||||
# What preflight.sh, the launcher and trace-hook call besides shell built-ins
|
||||
# and the tools under test. `iconv` is optional in the script itself.
|
||||
UTILITIES = ("dirname", "uname", "sed", "tr", "wc", "iconv", "tail", "cat", "mv", "head",
|
||||
"cut", "grep", "mkdir", "cp", "rm")
|
||||
|
||||
|
||||
def _shells() -> list[str]:
|
||||
found = {}
|
||||
for name in ("dash", "bash", "sh"):
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
found.setdefault(os.path.realpath(path), path)
|
||||
return sorted(found.values())
|
||||
|
||||
|
||||
SHELLS = _shells()
|
||||
|
||||
|
||||
def _executable(path: Path, text: str) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return path
|
||||
|
||||
|
||||
FAKE_PYTHON = """#!/bin/sh
|
||||
# A Python that answers the preflight's probe as itself, and fakes venv and pip.
|
||||
case "$1" in
|
||||
-c)
|
||||
case "$2" in
|
||||
*hashlib*) exec "{real}" "$@" ;;
|
||||
esac
|
||||
printf '%s\\n%s\\n' "${{FAKE_PY_VERSION:-3.13}}" "$0"
|
||||
exit 0 ;;
|
||||
-m)
|
||||
case "$2" in
|
||||
venv)
|
||||
shift 2
|
||||
[ "$1" = --clear ] && shift
|
||||
if [ -n "${{FAKE_VENV_FAIL:-}}" ]; then
|
||||
echo "Error: ensurepip is not available" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$1/bin"
|
||||
cp "$0" "$1/bin/python"
|
||||
exit 0 ;;
|
||||
pip)
|
||||
shift 2
|
||||
[ "$1" = --version ] && exit 0
|
||||
echo "$*" >> "$PIP_LOG"
|
||||
if [ -n "${{FAKE_PIP_FAIL:-}}" ]; then
|
||||
echo "ERROR: Could not find a version that satisfies the requirement (network unreachable)" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0 ;;
|
||||
esac ;;
|
||||
esac
|
||||
exit 1
|
||||
"""
|
||||
|
||||
|
||||
class Machine:
|
||||
"""A stack tree plus a PATH of stubs, for one preflight run or several."""
|
||||
|
||||
def __init__(self, base: Path, root_name: str = "instance"):
|
||||
self.base = base
|
||||
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"):
|
||||
shutil.copy2(TOOLS / name, self.tools / name)
|
||||
(self.tools / "requirements.txt").write_text("PyYAML\n", encoding="utf-8")
|
||||
self.sysbin = base / "sysbin"
|
||||
self.sysbin.mkdir(exist_ok=True)
|
||||
for name in UTILITIES:
|
||||
found = shutil.which(name)
|
||||
if found and not (self.sysbin / name).exists():
|
||||
(self.sysbin / name).symlink_to(found)
|
||||
self.stubs = base / "stubs"
|
||||
self.pip_log = base / "pip.log"
|
||||
self.extra_env: dict[str, str] = {}
|
||||
self.path_dirs: list[Path] = [self.stubs, self.sysbin]
|
||||
|
||||
def stub(self, name: str, text: str, where: Path | None = None) -> Path:
|
||||
return _executable((where or self.stubs) / name, text)
|
||||
|
||||
def python(self, name: str = "python3", where: Path | None = None) -> Path:
|
||||
return self.stub(name, FAKE_PYTHON.format(real=sys.executable), where)
|
||||
|
||||
def standard(self, rg: bool = True, pwsh: bool = False) -> "Machine":
|
||||
self.python()
|
||||
self.stub("git", "#!/bin/sh\necho 'git version 2.47.1'\n")
|
||||
if rg:
|
||||
self.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1 (rev 4649aa9700)'\n")
|
||||
if pwsh:
|
||||
self.stub("pwsh", "#!/bin/sh\necho 'PowerShell 7.6.6'\n")
|
||||
return self
|
||||
|
||||
def run(self, *args: str, shell: str) -> subprocess.CompletedProcess:
|
||||
env = {
|
||||
"PATH": os.pathsep.join(str(d) for d in self.path_dirs),
|
||||
"HOME": str(self.base),
|
||||
"PIP_LOG": str(self.pip_log),
|
||||
**self.extra_env,
|
||||
}
|
||||
return subprocess.run(
|
||||
[shell, str(self.tools / "preflight.sh"), *args],
|
||||
capture_output=True, text=True, env=env, timeout=60,
|
||||
)
|
||||
|
||||
@property
|
||||
def tools_file(self) -> Path:
|
||||
return self.root / toolpaths.FILE_NAME
|
||||
|
||||
def recorded(self) -> dict:
|
||||
return json.loads(self.tools_file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def assert_guidance(output: str, *fragments: str) -> None:
|
||||
"""Every exit-42 stop carries the block: what, why, the fixing command, what next."""
|
||||
assert "STOP" in output
|
||||
for marker in ("Why:", "Fix:", "Next:"):
|
||||
assert marker in output, f"no {marker!r} in:\n{output}"
|
||||
for fragment in fragments:
|
||||
assert fragment in output, f"no {fragment!r} in:\n{output}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def machine(tmp_path: Path) -> Machine:
|
||||
return Machine(tmp_path.resolve())
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(not SHELLS, reason="no POSIX shell on this machine")
|
||||
|
||||
|
||||
# --- the happy path ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_complete_path_records_absolute_paths_and_sets_up_the_venv(machine, shell):
|
||||
machine.standard()
|
||||
result = machine.run(shell=shell)
|
||||
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"}
|
||||
for path in data["tools"].values():
|
||||
assert os.path.isabs(path) and os.path.isfile(path)
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_second_run_changes_nothing(machine, shell):
|
||||
machine.standard()
|
||||
assert machine.run(shell=shell).returncode == 0
|
||||
before = machine.tools_file.read_text(encoding="utf-8")
|
||||
again = machine.run(shell=shell)
|
||||
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
|
||||
|
||||
|
||||
# --- stop cases -------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_missing_rg_stops_with_42_and_a_missing_line(machine, shell):
|
||||
machine.standard(rg=False)
|
||||
result = machine.run(shell=shell)
|
||||
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=")
|
||||
# What was found is kept for the next round of the loop, but not as ready.
|
||||
data = machine.recorded()
|
||||
assert data["complete"] is False and "rg" not in data["tools"]
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_python_too_old_stops(machine, shell):
|
||||
machine.standard()
|
||||
machine.extra_env["FAKE_PY_VERSION"] = "3.9"
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert "TOO_OLD" in result.stdout
|
||||
assert_guidance(result.stdout, "Python 3.9 is too old")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_invalid_set_path_writes_nothing(machine, shell):
|
||||
machine.standard()
|
||||
result = machine.run("--set", f"rg={machine.base / 'nowhere' / 'rg'}", shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "The path given for ripgrep (rg) does not work")
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_invalid_set_path_leaves_an_existing_file_alone(machine, shell):
|
||||
machine.standard()
|
||||
assert machine.run(shell=shell).returncode == 0
|
||||
before = machine.tools_file.read_text(encoding="utf-8")
|
||||
result = machine.run("--set", "git=/nowhere/git", shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert machine.tools_file.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_valid_set_path_is_recorded_and_kept(machine, shell):
|
||||
machine.standard(rg=False)
|
||||
elsewhere = machine.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1'\n", machine.base / "opt")
|
||||
result = machine.run("--set", f"rg={elsewhere}", shell=shell)
|
||||
assert result.returncode == 0, result.stdout
|
||||
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
|
||||
# The next run finds it in the file, although it is still not on PATH.
|
||||
again = machine.run(shell=shell)
|
||||
assert again.returncode == 0, again.stdout
|
||||
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_set_for_a_tool_this_platform_does_not_need_is_a_usage_error(machine, shell):
|
||||
machine.standard()
|
||||
result = machine.run("--set", "nonsense=/bin/true", shell=shell)
|
||||
assert result.returncode == 1
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_venv_that_cannot_be_created_stops(machine, shell):
|
||||
machine.standard()
|
||||
machine.extra_env["FAKE_VENV_FAIL"] = "1"
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "tools/.venv) could not be created", "ensurepip is not available")
|
||||
assert machine.recorded()["complete"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_pip_failure_stops_and_is_retried_next_time(machine, shell):
|
||||
machine.standard()
|
||||
machine.extra_env["FAKE_PIP_FAIL"] = "1"
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "could not be installed into tools/.venv", "network unreachable")
|
||||
assert machine.recorded()["complete"] is False
|
||||
del machine.extra_env["FAKE_PIP_FAIL"]
|
||||
assert machine.run(shell=shell).returncode == 0
|
||||
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 2
|
||||
|
||||
|
||||
# --- the store alias ----------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
@pytest.mark.parametrize("platform, alias", [("linux", "python3"), ("windows", "python")])
|
||||
def test_store_alias_is_never_run_and_never_recorded(machine, shell, 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.standard(pwsh=True)
|
||||
machine.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": platform, "CHEMENU_PREFLIGHT_LONGPATHS": "1"})
|
||||
if platform == "windows":
|
||||
machine.python("python") # the real one, behind the alias on PATH
|
||||
result = machine.run(shell=shell)
|
||||
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()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_windows_needs_pwsh_too(machine, shell):
|
||||
machine.standard(pwsh=False)
|
||||
machine.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": "windows", "CHEMENU_PREFLIGHT_LONGPATHS": "1"})
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "PowerShell 7 (pwsh) was not found")
|
||||
|
||||
|
||||
# --- install folder length (D32) --------------------------------------------------
|
||||
|
||||
|
||||
def _machine_with_root_of(tmp_path: Path, length: int) -> Machine:
|
||||
base = tmp_path.resolve()
|
||||
name_length = length - len(str(base)) - 1
|
||||
assert name_length > 0, "tmp_path is too long for this test"
|
||||
machine = Machine(base, "r" * name_length)
|
||||
assert len(str(machine.root)) == length
|
||||
machine.standard(pwsh=True)
|
||||
machine.extra_env["CHEMENU_PREFLIGHT_PLATFORM"] = "windows"
|
||||
return machine
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
@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, shell, length, longpaths, expected):
|
||||
machine = _machine_with_root_of(tmp_path, length)
|
||||
machine.extra_env["CHEMENU_PREFLIGHT_LONGPATHS"] = longpaths
|
||||
result = machine.run(shell=shell)
|
||||
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()
|
||||
|
||||
|
||||
def test_manifest_limit_and_path_budget_fit_max_path_together():
|
||||
"""95 + separator + 160 must stay within Windows' 259 - the two numbers are
|
||||
one sum, kept in two places (tools/prerequisites.txt, titles.PATH_BUDGET)."""
|
||||
limit = prerequisites.load_manifest().limits["install_dir_max"]
|
||||
assert limit + 1 + titles.PATH_BUDGET <= 259
|
||||
|
||||
|
||||
def test_manifest_names_the_tools_the_stack_starts():
|
||||
manifest = prerequisites.load_manifest()
|
||||
assert [t.name for t in manifest.tools_for("linux")] == ["python", "git", "rg"]
|
||||
assert [t.name for t in manifest.tools_for("windows")] == ["python", "git", "rg", "pwsh"]
|
||||
|
||||
|
||||
# --- the launcher -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _launch(machine: Machine, *args: str) -> subprocess.CompletedProcess:
|
||||
env = {"PATH": os.pathsep.join(str(d) for d in machine.path_dirs), "HOME": str(machine.base)}
|
||||
return subprocess.run([str(machine.tools / "wikitool"), *args],
|
||||
capture_output=True, text=True, env=env, timeout=30)
|
||||
|
||||
|
||||
ECHO_PYTHON = "#!/bin/sh\nprintf '%s\\n' \"$@\"\n"
|
||||
|
||||
|
||||
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.sh" in result.stderr
|
||||
assert "Traceback" not in result.stdout + 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_there_is_a_powershell_launcher_slot_but_no_cmd():
|
||||
assert (TOOLS / "wikitool").is_file()
|
||||
assert not (TOOLS / "wikitool.cmd").exists()
|
||||
|
||||
|
||||
# --- trace-hook -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hook(machine: Machine, *args: str) -> subprocess.CompletedProcess:
|
||||
env = {"PATH": os.pathsep.join(str(d) for d in machine.path_dirs), "HOME": str(machine.base)}
|
||||
return subprocess.run([str(machine.tools / "trace-hook"), *args],
|
||||
capture_output=True, text=True, env=env, timeout=30)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout", [("bin", "python"), ("Scripts", "python.exe")])
|
||||
def test_trace_hook_runs_trace_ingest_with_the_venv_python(machine, layout):
|
||||
machine.stub(layout[1], ECHO_PYTHON, machine.tools / ".venv" / layout[0])
|
||||
result = _hook(machine, "--source", "claude-code", "--event", "prompt.submitted")
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.splitlines() == [
|
||||
str(machine.tools / "trace_ingest.py"), "--source", "claude-code", "--event", "prompt.submitted",
|
||||
]
|
||||
|
||||
|
||||
def test_trace_hook_is_silent_without_a_venv(machine):
|
||||
result = _hook(machine, "--source", "claude-code")
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "" and result.stderr == ""
|
||||
|
||||
|
||||
def _hook_commands() -> list[str]:
|
||||
root = config._PACKAGE_ROOT
|
||||
commands = []
|
||||
claude = json.loads((root / ".claude" / "settings.json").read_text(encoding="utf-8"))
|
||||
for entries in claude["hooks"].values():
|
||||
for entry in entries:
|
||||
commands += [hook["command"] for hook in entry["hooks"]]
|
||||
copilot = json.loads((root / ".github" / "hooks" / "wiki-trace.json").read_text(encoding="utf-8"))
|
||||
for entries in copilot["hooks"].values():
|
||||
for hook in entries:
|
||||
commands += [hook["bash"], hook["powershell"]]
|
||||
import tomllib
|
||||
|
||||
vibe = tomllib.loads((root / ".vibe" / "hooks.toml").read_text(encoding="utf-8"))
|
||||
commands += [hook["command"] for hook in vibe["hooks"]]
|
||||
return commands
|
||||
|
||||
|
||||
def test_no_hook_relies_on_a_shebang_or_a_bare_python():
|
||||
commands = _hook_commands()
|
||||
assert len(commands) > 10
|
||||
for command in commands:
|
||||
assert command.startswith(("./tools/trace-hook ", ".\\tools\\.venv\\Scripts\\python.exe ")), command
|
||||
|
||||
|
||||
# --- toolpaths --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _point_at(monkeypatch, path: Path) -> Path:
|
||||
monkeypatch.setattr(toolpaths, "tools_file", lambda: path)
|
||||
return path
|
||||
|
||||
|
||||
def test_no_file_means_the_bare_name(tmp_path, monkeypatch):
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME)
|
||||
assert toolpaths.git() == "git" and toolpaths.rg() == "rg"
|
||||
|
||||
|
||||
def test_recorded_path_is_used(tmp_path, monkeypatch):
|
||||
rg = _executable(tmp_path / "bin" / "rg", "#!/bin/sh\n")
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(
|
||||
json.dumps({"schema": 1, "complete": True, "tools": {"rg": str(rg)}}), encoding="utf-8")
|
||||
assert toolpaths.rg() == str(rg)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content, message", [
|
||||
('{"schema": 1, "complete": true, "tools": {}}', "not recorded"),
|
||||
('{"schema": 1, "complete": true, "tools": {"git": "/nowhere/git"}}', "no longer exists"),
|
||||
("{not json", "not valid JSON"),
|
||||
('{"schema": 2, "tools": {}}', "shape this version reads"),
|
||||
])
|
||||
def test_a_present_but_unusable_file_never_falls_back_to_path(tmp_path, monkeypatch, content, message):
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(content, encoding="utf-8")
|
||||
with pytest.raises(toolpaths.ToolPathError, match=message) as caught:
|
||||
toolpaths.git()
|
||||
assert "tools/preflight.sh" in str(caught.value)
|
||||
assert not isinstance(caught.value, OSError)
|
||||
|
||||
|
||||
def _logging_wrapper(directory: Path, name: str, log: Path) -> Path:
|
||||
real = shutil.which(name)
|
||||
assert real, f"{name} is needed for this test"
|
||||
return _executable(directory / name, f"#!/bin/sh\necho \"$@\" >> '{log}'\nexec '{real}' \"$@\"\n")
|
||||
|
||||
|
||||
def test_git_and_rg_start_from_the_recorded_paths_not_from_path(tmp_path, monkeypatch):
|
||||
log = tmp_path / "calls.log"
|
||||
hidden = tmp_path / "not-on-path"
|
||||
git = _logging_wrapper(hidden, "git", log)
|
||||
rg = _logging_wrapper(hidden, "rg", log)
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(json.dumps(
|
||||
{"schema": 1, "complete": True, "tools": {"git": str(git), "rg": str(rg)}}), encoding="utf-8")
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Recorded Git"], cwd=repo, check=True)
|
||||
(repo / "kb").mkdir()
|
||||
(repo / "kb" / "Page.md").write_text("---\ntitle: Page\n---\nkingfisher\n", encoding="utf-8")
|
||||
monkeypatch.setattr(config, "ROOT", repo)
|
||||
monkeypatch.setenv("PATH", str(tmp_path / "empty"))
|
||||
|
||||
assert config.default_author() == "Recorded Git"
|
||||
|
||||
from chemenu.search.base import SearchQuery
|
||||
from chemenu.search.ripgrep import RipgrepBackend
|
||||
backend = RipgrepBackend(search_root=repo / "kb", repo_root=repo)
|
||||
backend.search(SearchQuery(text="kingfisher"), {})
|
||||
|
||||
calls = log.read_text(encoding="utf-8")
|
||||
assert "config user.name" in calls
|
||||
assert "kingfisher" in calls
|
||||
|
||||
|
||||
def test_cli_turns_a_tool_path_error_into_an_error_line(tmp_path, monkeypatch, capsys):
|
||||
from chemenu import cli
|
||||
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(
|
||||
'{"schema": 1, "complete": true, "tools": {}}', encoding="utf-8")
|
||||
monkeypatch.setattr(sys, "argv", ["wikitool", "search", "kingfisher"])
|
||||
with pytest.raises(SystemExit) as exited:
|
||||
cli._run_traced("search", ["kingfisher"])
|
||||
assert exited.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "ERROR" in out and "tools/preflight.sh" in out
|
||||
Reference in new issue
Block a user