Files changed: - .gitignore - AGENTS.md - CHANGES.md - VERSION - docs/explanation/architecture.md - docs/how-to/README.md - docs/how-to/write-a-site-handler.md - docs/reference/commands/instance-health.md - docs/reference/commands/raw-material-and-uploads.md - docs/reference/configuration.md - instructions/dev/doc-pull-through.md - raw/CONTRACT.md - sites/CONTRACT.md - tools/CONTRACT.md - tools/README.md - 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/raw/fetch.py - tools/chemenu/ownership.py - tools/chemenu/sites/__init__.py - tools/chemenu/sites/data.py - tools/chemenu/sites/handlers/learn-microsoft-powershell-module.json - tools/chemenu/sites/handlers/learn-microsoft.json - tools/chemenu/sites/learn_microsoft.py - tools/chemenu/sites/site.schema.json - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_site_files.py - tools/chemenu/toc.py Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
743 lines
30 KiB
Python
743 lines
30 KiB
Python
"""Tests for `wikitool doctor`: a healthy instance reports all OK/WARN and
|
|
never FAIL, and each check independently reports FAIL when its precondition
|
|
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, prerequisites, toolpaths
|
|
from chemenu.commands import doctor, instructions_cmd
|
|
from chemenu.tests.conftest import write_settings
|
|
|
|
|
|
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,
|
|
the required stage contracts, one kb collection, generated files, and a
|
|
published skill."""
|
|
root = tmp_path
|
|
kb = root / "kb"
|
|
for sub in ("entities", "concepts", "sources", "comparisons"):
|
|
(kb / sub).mkdir(parents=True)
|
|
(kb / sub / "COLLECTION.md").write_text(
|
|
f"---\nprofile: {sub}\nrequired_by_stack: "
|
|
f"{'true' if sub == 'sources' else 'false'}\n---\n\n# {sub}\n",
|
|
encoding="utf-8",
|
|
)
|
|
(kb / "index.md").write_text("# Index\n", encoding="utf-8")
|
|
(kb / "log.md").write_text("# Log\n", encoding="utf-8")
|
|
(kb / "provenance.md").write_text("# Provenance\n", encoding="utf-8")
|
|
(kb / "CONTRACT.md").write_text("# kb contract\n", encoding="utf-8")
|
|
(kb / "CONVENTIONS.md").write_text(
|
|
"---\nlanguage: en\nprofile: none\nsections:\n"
|
|
" links: Relationships\n footnotes: Footnotes\n"
|
|
"---\n\n# conventions\n",
|
|
encoding="utf-8",
|
|
)
|
|
(root / "VERSION").write_text("0.1.0\n", encoding="utf-8")
|
|
(root / "USER.md").write_text("# USER.md - Fixture\n", encoding="utf-8")
|
|
(root / "SOUL.md").write_text("# SOUL.md - Fixture\n", encoding="utf-8")
|
|
|
|
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md",
|
|
"sites/CONTRACT.md", "instructions/CONTRACT.md", "types/type-spec.md"):
|
|
path = root / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("# contract\n", encoding="utf-8")
|
|
|
|
instructions = root / "instructions"
|
|
(instructions / "wiki-demo").mkdir(parents=True)
|
|
(instructions / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n", encoding="utf-8"
|
|
)
|
|
|
|
monkeypatch.setattr(config, "ROOT", root)
|
|
monkeypatch.setattr(config, "KB_DIR", kb)
|
|
monkeypatch.setattr(config, "INDEX_FILE", kb / "index.md")
|
|
monkeypatch.setattr(config, "LOG_FILE", kb / "log.md")
|
|
monkeypatch.setattr(config, "PROVENANCE_FILE", kb / "provenance.md")
|
|
monkeypatch.setattr(config, "INSTRUCTIONS_DIR", instructions)
|
|
monkeypatch.setattr(config, "AGENTS_SKILLS_DIR", root / ".agents" / "skills")
|
|
monkeypatch.setattr(config, "CLAUDE_SKILLS_DIR", root / ".claude" / "skills")
|
|
monkeypatch.setenv("WIKI_AUTHOR", "Fixture Author")
|
|
monkeypatch.delenv("WIKITOOL_SESSION_ID", raising=False)
|
|
|
|
_git(root, "init", "-b", "main")
|
|
_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
|
|
|
|
|
|
def _status(checks, name):
|
|
return next(c.status for c in checks if c.name == name)
|
|
|
|
|
|
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)
|
|
assert _status(checks, "structure") == "OK"
|
|
assert _status(checks, "generated-files") == "OK"
|
|
assert _status(checks, "skills") == "OK"
|
|
assert _status(checks, "author") == "OK"
|
|
assert _status(checks, "git-identity") == "OK"
|
|
|
|
|
|
def test_healthy_instance_warns_on_missing_remote_and_session_id(instance):
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "git-remote") == "WARN"
|
|
assert _status(checks, "session-id") == "WARN"
|
|
|
|
|
|
def test_stack_version_is_reported(instance):
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "stack-version") == "OK"
|
|
detail = next(c.detail for c in checks if c.name == "stack-version")
|
|
assert "0.1.0" in detail and "development tree" in detail
|
|
|
|
|
|
def test_a_stamped_instance_reports_itself_as_a_distribution(instance):
|
|
(config.ROOT / ".wikitool-release.json").write_text(
|
|
'{"version": "0.1.0", "exported_at": "2026-08-29"}', encoding="utf-8"
|
|
)
|
|
detail = next(c.detail for c in doctor.run_doctor() if c.name == "stack-version")
|
|
assert "distribution" in detail and "2026-08-29" in detail
|
|
|
|
|
|
def test_a_missing_version_warns_rather_than_fails(instance):
|
|
"""Instances exported before the stack was versioned are still perfectly
|
|
functional - they just cannot answer `version check`."""
|
|
(config.ROOT / "VERSION").unlink()
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "stack-version") == "WARN"
|
|
assert not any(c.status == "FAIL" for c in checks)
|
|
|
|
|
|
def test_a_malformed_version_fails(instance):
|
|
(config.ROOT / "VERSION").write_text("v1\n", encoding="utf-8")
|
|
assert _status(doctor.run_doctor(), "stack-version") == "FAIL"
|
|
|
|
|
|
def test_kb_version_warns_when_the_content_is_undeclared(instance):
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "kb-version") == "WARN"
|
|
assert not any(c.status == "FAIL" for c in checks)
|
|
|
|
|
|
def test_kb_version_is_ok_when_it_matches_the_machinery(instance):
|
|
(config.ROOT / ".wikitool-kb.json").write_text(
|
|
'{"schema": 1, "kb_version": "0.1.0", "applied": []}', encoding="utf-8"
|
|
)
|
|
assert _status(doctor.run_doctor(), "kb-version") == "OK"
|
|
|
|
|
|
def test_kb_version_warns_while_a_migration_is_outstanding(instance):
|
|
"""The normal, transient state in the middle of an upgrade - a WARN that
|
|
names the chain, not a fault."""
|
|
(config.ROOT / "VERSION").write_text("2.0.0\n", encoding="utf-8")
|
|
(config.ROOT / ".wikitool-kb.json").write_text(
|
|
'{"schema": 1, "kb_version": "1.0.0", "applied": []}', encoding="utf-8"
|
|
)
|
|
migrations = config.INSTRUCTIONS_DIR / "migrations"
|
|
migrations.mkdir(parents=True, exist_ok=True)
|
|
(migrations / "2.0.0-retype.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
|
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "kb-version") == "WARN"
|
|
assert "outstanding" in next(c.detail for c in checks if c.name == "kb-version")
|
|
assert not any(c.status == "FAIL" for c in checks)
|
|
|
|
|
|
def test_an_unreadable_kb_state_fails(instance):
|
|
(config.ROOT / ".wikitool-kb.json").write_text("{broken", encoding="utf-8")
|
|
assert _status(doctor.run_doctor(), "kb-version") == "FAIL"
|
|
|
|
|
|
def test_a_running_candidate_is_named_as_such(instance):
|
|
(config.ROOT / "VERSION").write_text("0.2.0-beta.1\n", encoding="utf-8")
|
|
detail = next(c.detail for c in doctor.run_doctor() if c.name == "stack-version")
|
|
assert "0.2.0-beta.1" in detail
|
|
assert "candidate" in detail
|
|
|
|
|
|
def test_kb_version_chain_still_reaches_a_target_matching_a_running_candidate(instance):
|
|
"""Regression for finding #3: comparing the chain against the raw
|
|
candidate would sort `2.0.0` (the migration's target) *before*
|
|
`2.0.0-beta.1` (what is installed), dropping it out of the owed range."""
|
|
(config.ROOT / "VERSION").write_text("2.0.0-beta.1\n", encoding="utf-8")
|
|
(config.ROOT / ".wikitool-kb.json").write_text(
|
|
'{"schema": 1, "kb_version": "1.0.0", "applied": []}', encoding="utf-8"
|
|
)
|
|
migrations = config.INSTRUCTIONS_DIR / "migrations"
|
|
migrations.mkdir(parents=True, exist_ok=True)
|
|
(migrations / "2.0.0-retype.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
|
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "kb-version") == "WARN"
|
|
assert "outstanding" in next(c.detail for c in checks if c.name == "kb-version")
|
|
|
|
|
|
def test_no_collections_at_all_fails_structure(instance):
|
|
"""Removing one collection is a legitimate state - collections are
|
|
discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md).
|
|
Having none at all means the kb layer never got its Areas populated."""
|
|
import shutil
|
|
|
|
for sub in ("entities", "concepts", "sources", "comparisons"):
|
|
shutil.rmtree(config.KB_DIR / sub)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "structure") == "FAIL"
|
|
|
|
|
|
def test_missing_stage_contract_fails_structure(instance):
|
|
(config.ROOT / "raw" / "CONTRACT.md").unlink()
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "structure") == "FAIL"
|
|
|
|
|
|
def test_personalization_is_ok_when_both_files_are_filled(instance):
|
|
assert _status(doctor.run_doctor(), "personalization") == "OK"
|
|
|
|
|
|
def test_missing_personalization_fails(instance):
|
|
"""`USER.md`/`SOUL.md` are read every session, so an instance without them
|
|
runs a generic agent against a wiki built for one person."""
|
|
(config.ROOT / "SOUL.md").unlink()
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "personalization") == "FAIL"
|
|
assert "SOUL.md" in next(c.detail for c in checks if c.name == "personalization")
|
|
|
|
|
|
def test_a_renamed_but_unfilled_template_fails(instance):
|
|
"""The failure mode a plain existence check would miss: the file is
|
|
present and answers nothing."""
|
|
(config.ROOT / "USER.md").write_text(
|
|
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# USER.md - <Name>\n", encoding="utf-8"
|
|
)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "personalization") == "FAIL"
|
|
assert "template" in next(c.detail for c in checks if c.name == "personalization")
|
|
|
|
|
|
def test_conventions_are_ok_when_declared(instance):
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "conventions") == "OK"
|
|
assert "Relationships" in next(c.detail for c in checks if c.name == "conventions")
|
|
|
|
|
|
def test_missing_conventions_fail(instance):
|
|
"""Unlike `ENVIRONMENT.md`, this one is not optional: `xref add` and
|
|
`cite add` write headings out of it, so an instance without it is being
|
|
answered by whatever the stack hardcoded before the file existed."""
|
|
(config.KB_DIR / conventions.CONVENTIONS_FILENAME).unlink()
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "conventions") == "FAIL"
|
|
assert "missing" in next(c.detail for c in checks if c.name == "conventions")
|
|
|
|
|
|
def test_conventions_with_an_incomplete_sections_block_fail(instance):
|
|
"""Present and deciding nothing - the same failure mode the personalization
|
|
sentinel check exists for, one directory down."""
|
|
(config.KB_DIR / conventions.CONVENTIONS_FILENAME).write_text(
|
|
"---\nlanguage: en\nsections:\n links: Relationships\n---\n", encoding="utf-8"
|
|
)
|
|
conventions.reset_cache()
|
|
assert _status(doctor.run_doctor(), "conventions") == "FAIL"
|
|
|
|
|
|
def test_environment_is_ok_when_absent(instance):
|
|
"""The file is optional, so absence is a healthy end state - a FAIL here
|
|
would make it mandatory through the back door."""
|
|
assert not (config.ROOT / config.ENVIRONMENT_FILE).exists()
|
|
assert _status(doctor.run_doctor(), "environment") == "OK"
|
|
|
|
|
|
def test_environment_is_ok_when_filled(instance):
|
|
(config.ROOT / config.ENVIRONMENT_FILE).write_text(
|
|
"# ENVIRONMENT.md - Fixture\n\n- Harness: none\n", encoding="utf-8"
|
|
)
|
|
assert _status(doctor.run_doctor(), "environment") == "OK"
|
|
|
|
|
|
def test_a_renamed_but_unfilled_environment_template_warns(instance):
|
|
"""Present, loaded into every session, and answering nothing - worse than
|
|
absent, which is at least honest. A WARN, not a FAIL: the fix may well be
|
|
to delete the file again."""
|
|
(config.ROOT / config.ENVIRONMENT_FILE).write_text(
|
|
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# ENVIRONMENT.md - <Instanz>\n", encoding="utf-8"
|
|
)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "environment") == "WARN"
|
|
assert "template" in next(c.detail for c in checks if c.name == "environment")
|
|
|
|
|
|
_THRESHOLDS = {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5}
|
|
|
|
|
|
def _remotes_detail(checks) -> str:
|
|
return next(c.detail for c in checks if c.name == "publish-remotes")
|
|
|
|
|
|
def test_publish_remotes_says_not_armed_when_the_section_is_absent(instance):
|
|
"""Absence stays OK - a single-remote checkout has nothing to protect - but
|
|
the line has to say the gate is off. AGENTS.md lists it among the limits
|
|
enforced in code, so "no remotes section" alone leaves a reader
|
|
trusting a safeguard that is not running."""
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "publish-remotes") == "OK"
|
|
assert "not armed" in _remotes_detail(checks)
|
|
|
|
|
|
def test_publish_remotes_says_armed_when_the_section_lists_a_target(instance, monkeypatch):
|
|
write_settings(config.ROOT, monkeypatch,
|
|
remotes={"allowed_push_urls": ["ssh://git@example.net/one.git"]})
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "publish-remotes") == "OK"
|
|
detail = _remotes_detail(checks)
|
|
assert "armed" in detail and "not armed" not in detail
|
|
|
|
|
|
def test_publish_remotes_warns_on_several_remotes_without_an_allowlist(instance):
|
|
"""The shape a private instance has once it adds the public upstream."""
|
|
_git(config.ROOT, "remote", "add", "origin", "ssh://git@example.net/mine.git")
|
|
_git(config.ROOT, "remote", "add", "upstream", "ssh://git@example.net/theirs.git")
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "publish-remotes") == "WARN"
|
|
assert "not armed" in _remotes_detail(checks)
|
|
|
|
|
|
def test_upload_intake_ok_when_no_section(instance):
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "upload-intake") == "OK"
|
|
assert "not registered" in _detail(checks, "upload-intake")
|
|
|
|
|
|
def test_upload_intake_ok_and_armed_when_section_is_valid(instance, monkeypatch):
|
|
write_settings(config.ROOT, monkeypatch, upload={
|
|
"max_bytes": 100, "allowed_extensions": [".md"],
|
|
"quota_submissions_per_day": 1, "quota_bytes_per_day": 100,
|
|
})
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "upload-intake") == "OK"
|
|
detail = _detail(checks, "upload-intake")
|
|
assert "armed" in detail
|
|
assert "0 submission(s) waiting" in detail
|
|
|
|
|
|
def test_upload_intake_fails_on_a_malformed_section(instance, monkeypatch):
|
|
write_settings(config.ROOT, monkeypatch, upload={"max_bytes": "many"})
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "upload-intake") == "FAIL"
|
|
|
|
|
|
def test_tasks_provider_ok_when_no_section(instance):
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "tasks-provider") == "OK"
|
|
assert "no task tracker configured" in _detail(checks, "tasks-provider")
|
|
|
|
|
|
def test_tasks_provider_fails_on_a_malformed_settings_file(instance):
|
|
(config.ROOT / config.LOCAL_SETTINGS_FILENAME).write_text("{not json", encoding="utf-8")
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "tasks-provider") == "FAIL"
|
|
|
|
|
|
def test_tasks_provider_fails_on_a_bad_superproductivity_section(instance, monkeypatch):
|
|
write_settings(config.ROOT, monkeypatch, tasks={"provider": "superproductivity", **_THRESHOLDS})
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "tasks-provider") == "FAIL"
|
|
|
|
|
|
def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(instance, monkeypatch):
|
|
"""`access: snapshot` - only the read path is a finding; the API is not
|
|
even attempted (Gitea #133)."""
|
|
backups = config.ROOT / "sp-backups"
|
|
write_settings(config.ROOT, monkeypatch,
|
|
tasks={"provider": "superproductivity", **_THRESHOLDS},
|
|
superproductivity={"access": "snapshot", "backups_dir": str(backups)})
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "tasks-provider") == "OK"
|
|
detail = _detail(checks, "tasks-provider")
|
|
assert "access=snapshot" in detail
|
|
assert "not ready" in detail
|
|
|
|
|
|
def test_tasks_provider_ok_but_names_api_unreachable_when_configured(instance, monkeypatch):
|
|
"""`access: api` - the API reachability is the finding; the (irrelevant)
|
|
backup file is not even looked at (Gitea #133)."""
|
|
write_settings(config.ROOT, monkeypatch,
|
|
tasks={"provider": "superproductivity", **_THRESHOLDS},
|
|
superproductivity={"access": "api", "api_base_url": "http://127.0.0.1:1",
|
|
"api_token": "t"})
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "tasks-provider") == "OK"
|
|
detail = _detail(checks, "tasks-provider")
|
|
assert "access=api" in detail
|
|
assert "not reachable" in detail
|
|
|
|
|
|
def test_tasks_provider_fails_on_a_bad_caldav_section(instance, monkeypatch):
|
|
write_settings(config.ROOT, monkeypatch, tasks={"provider": "caldav", **_THRESHOLDS})
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "tasks-provider") == "FAIL"
|
|
|
|
|
|
def test_tasks_provider_ok_but_names_caldav_unreachable_when_configured(instance, monkeypatch):
|
|
write_settings(config.ROOT, monkeypatch, tasks={"provider": "caldav", **_THRESHOLDS}, caldav={
|
|
"url": "http://127.0.0.1:1/cal/", "username": "u", "app_password": "p",
|
|
"inbox_list": "Inbox", "someday_list": "Someday",
|
|
})
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "tasks-provider") == "OK"
|
|
detail = _detail(checks, "tasks-provider")
|
|
assert "caldav" in detail
|
|
assert "unreachable" in detail
|
|
|
|
|
|
@pytest.mark.parametrize("name", [".wikitool-remotes.json", ".wikitool-telemetry.json",
|
|
".wikitool-upload.json", ".wikitool-tasks.json"])
|
|
def test_a_leftover_settings_file_of_an_earlier_version_fails(instance, name):
|
|
(config.ROOT / name).write_text("{}", encoding="utf-8")
|
|
checks = doctor.run_doctor()
|
|
failing = [c for c in checks if c.name == "legacy-settings"]
|
|
assert [c.status for c in failing] == ["FAIL"]
|
|
assert name in failing[0].detail
|
|
assert "tools/wikitool config migrate" in (failing[0].fix or "")
|
|
|
|
|
|
def test_missing_generated_file_fails(instance):
|
|
config.LOG_FILE.unlink()
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "generated-files") == "FAIL"
|
|
|
|
|
|
def test_unpublished_skills_fail(instance):
|
|
import shutil
|
|
|
|
shutil.rmtree(config.AGENTS_SKILLS_DIR)
|
|
shutil.rmtree(config.CLAUDE_SKILLS_DIR)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "skills") == "FAIL"
|
|
|
|
|
|
def test_no_author_fails(instance, monkeypatch):
|
|
monkeypatch.delenv("WIKI_AUTHOR", raising=False)
|
|
monkeypatch.setattr(config, "default_author", lambda: None)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "author") == "FAIL"
|
|
|
|
|
|
def test_not_a_git_repo_fails(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "git-repo") == "FAIL"
|
|
|
|
|
|
def test_telemetry_is_on_by_default_for_a_dev_checkout(instance):
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "telemetry") == "OK"
|
|
detail = next(c.detail for c in checks if c.name == "telemetry")
|
|
assert detail.startswith("on (")
|
|
assert "0/250 session(s)" in detail
|
|
|
|
|
|
def test_telemetry_is_off_by_default_for_a_stamped_distribution(instance):
|
|
(config.ROOT / ".wikitool-release.json").write_text(
|
|
'{"version": "0.1.0", "exported_at": "2026-08-29"}', encoding="utf-8"
|
|
)
|
|
detail = next(c.detail for c in doctor.run_doctor() if c.name == "telemetry")
|
|
assert detail.startswith("off (")
|
|
assert ".wikitool-release.json present" in detail
|
|
|
|
|
|
def test_telemetry_never_fails_either_way(instance):
|
|
(config.ROOT / ".wikitool-release.json").write_text(
|
|
'{"version": "0.1.0", "exported_at": "2026-08-29"}', encoding="utf-8"
|
|
)
|
|
assert _status(doctor.run_doctor(), "telemetry") == "OK"
|
|
|
|
|
|
def _telemetry_with(section: "str | None", raw: "str | None" = None):
|
|
"""`check_telemetry` against a `telemetry` section of `.wikitool-local.json`
|
|
holding the JSON text `section` (absent for None; `raw` replaces the whole
|
|
file instead), plus what the policy itself makes of it."""
|
|
from chemenu.telemetry import policy
|
|
|
|
path = config.ROOT / config.LOCAL_SETTINGS_FILENAME
|
|
if raw is not None:
|
|
path.write_text(raw, encoding="utf-8")
|
|
elif section is None:
|
|
path.unlink(missing_ok=True)
|
|
else:
|
|
path.write_text('{"schema": 1, "telemetry": %s}' % section, encoding="utf-8")
|
|
policy.reset_cache()
|
|
try:
|
|
return doctor.check_telemetry(), policy.resolve(config.ROOT)
|
|
finally:
|
|
policy.reset_cache()
|
|
|
|
|
|
@pytest.mark.parametrize("text", [None, '{"enabled": false}', '{"enabled": true, '
|
|
'"max_session_bytes": 1000, "keep_sessions": 9}'])
|
|
def test_telemetry_stays_ok_for_an_absent_or_conforming_section(instance, text):
|
|
check, _ = _telemetry_with(text)
|
|
assert check.status == "OK"
|
|
assert check.fix is None
|
|
|
|
|
|
@pytest.mark.parametrize("text,named,policy_reads", [
|
|
("[true]", "object", (True, 5 * 1024 * 1024, 250)),
|
|
('{"enabled": "no"}', "enabled", (True, 5 * 1024 * 1024, 250)),
|
|
('{"max_session_bytes": 0}', "max_session_bytes", (True, 5 * 1024 * 1024, 250)),
|
|
('{"keep_sessions": "12"}', "keep_sessions", (True, 5 * 1024 * 1024, 12)),
|
|
('{"enabled": false, "keep_session": 12}', "keep_session", (False, 5 * 1024 * 1024, 250)),
|
|
])
|
|
def test_telemetry_warns_on_what_the_policy_reads_differently(instance, text, named, policy_reads):
|
|
"""The policy stays silent and reads the section exactly as before; `doctor`
|
|
is where the deviation becomes visible, by field."""
|
|
check, pol = _telemetry_with(text)
|
|
assert check.status == "WARN"
|
|
assert named in check.detail
|
|
assert "telemetry" in check.fix
|
|
assert (pol.enabled, pol.max_session_bytes, pol.keep_sessions) == policy_reads
|
|
|
|
|
|
def test_telemetry_warns_on_an_unreadable_settings_file_and_the_policy_stays_on_its_default(instance):
|
|
check, pol = _telemetry_with(None, raw="{not json")
|
|
assert check.status == "WARN"
|
|
assert "unreadable" in check.detail
|
|
assert (pol.enabled, pol.max_session_bytes, pol.keep_sessions) == (True, 5 * 1024 * 1024, 250)
|
|
|
|
|
|
def test_telemetry_reports_the_current_session_count_and_byte_total(instance, monkeypatch):
|
|
from chemenu.telemetry.writer import TRACE_FILE, trace_root
|
|
|
|
trace_dir = config.ROOT / "traces"
|
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(trace_dir))
|
|
for name, content in (("a", "111"), ("b", "22222")):
|
|
session_dir = trace_root() / name
|
|
session_dir.mkdir(parents=True)
|
|
(session_dir / TRACE_FILE).write_text(content, encoding="utf-8")
|
|
|
|
detail = next(c.detail for c in doctor.run_doctor() if c.name == "telemetry")
|
|
assert "2/250 session(s)" in detail
|
|
assert "8 byte(s)" in detail # "111" + "22222"
|
|
|
|
|
|
def test_doctor_command_exits_nonzero_only_on_fail(instance, capsys):
|
|
import typer
|
|
|
|
doctor.doctor_command(json_out=False) # no FAIL - must not raise
|
|
out = capsys.readouterr().out
|
|
assert "OK" in out
|
|
|
|
with pytest.raises(typer.Exit) as excinfo:
|
|
config.LOG_FILE.unlink()
|
|
doctor.doctor_command(json_out=False)
|
|
assert excinfo.value.exit_code == 1
|
|
|
|
|
|
def test_doctor_json_is_machine_readable(instance, capsys):
|
|
import json
|
|
|
|
doctor.doctor_command(json_out=True)
|
|
rows = json.loads(capsys.readouterr().out)
|
|
assert any(row["name"] == "structure" for row in rows)
|
|
assert all({"name", "status", "detail", "fix"} <= row.keys() for row in rows)
|
|
|
|
|
|
def test_tasks_provider_fails_when_the_override_names_a_missing_file(instance, monkeypatch):
|
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(config.ROOT / "gone.json"))
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "tasks-provider") == "FAIL"
|
|
assert "gone.json" in _detail(checks, "tasks-provider")
|
|
|
|
|
|
def test_tasks_provider_names_an_active_override(instance, monkeypatch):
|
|
profile = config.ROOT / "profile.json"
|
|
profile.write_text(
|
|
json.dumps({
|
|
"schema": 1,
|
|
"tasks": {"provider": "superproductivity", **_THRESHOLDS},
|
|
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:1",
|
|
"api_token": {"ref": "profile_token"}},
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("WIKITOOL_SECRET_PROFILE_TOKEN", "t")
|
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
|
|
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"
|
|
|
|
|
|
# --- 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"
|