Files
chemenu/tools/chemenu/tests/test_tracker_live.py
T
torbenandClaude Code d09247d9aa
CI / verify (push) Failing after 7m51s
CI / pwsh (push) Successful in 2m34s
tests: the live tracker suite's CalDAV provisioner imports config_shapes and settings (red CI run 649 on db8f90c)
Files changed:
- tools/chemenu/tests/test_tracker_live.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-11 07:04:06 +02:00

284 lines
11 KiB
Python

"""The live tracker suite (Gitea #156) and the proof that its safety rules hold.
The `live_tracker` tests need a tracker and skip without one; the rest of this file
never does. Which tracker, how to start one, and what to do with a red run:
`instructions/dev/tracker-testing.md`.
"""
from __future__ import annotations
import contextlib
import http.server
import json
import threading
from datetime import date
from pathlib import Path
import pytest
from typer.testing import CliRunner
from chemenu import config_shapes, settings
from chemenu.cli import app
from chemenu.review import CHECK_WAITING_OVERDUE, run_review
from chemenu.frontmatter_io import write_page
from chemenu.tasks.protocol import (
OpenItems, ProjectSummary, ReadSource, SomedayItem,
)
from chemenu.tests import sp_headless
from chemenu.tests import tracker_live as live
from chemenu.tests.tracker_live import LiveAbort, LiveTarget
runner = CliRunner()
# --- the live scenario ---------------------------------------------------------------------
@pytest.fixture(scope="session")
def live_pool():
pool: dict[str, "LiveTarget | None"] = {}
yield pool
for target in pool.values():
if target is not None:
target.cleanup()
@pytest.fixture(params=live.KINDS)
def target(request, live_pool) -> LiveTarget:
kind = request.param
if kind not in live_pool:
try:
live_pool[kind] = live.build_target(kind)
except LiveAbort as exc:
pytest.fail(f"live tracker '{kind}' refused to start: {exc}")
found = live_pool[kind]
if found is None:
if kind in live.required_kinds():
pytest.fail(f"CHEMENU_LIVE_REQUIRE names '{kind}' but no such tracker is configured")
pytest.skip(f"no live '{kind}' tracker configured (instructions/dev/tracker-testing.md)")
return found
def _project_page(root: Path, name: str) -> None:
write_page(
root / "kb" / "gtd" / "technik" / f"{name}.md",
{
"type": "types/project.md", "state": "active", "responsibility": "technik",
"created": "2026-01-01", "modified": "2026-01-01", "provenance": "general",
"summary": f"Live-test project {name}.",
},
f"\n# {name}\n\n## Ziel\n\nLive test.\n",
)
def _provisioner(target: LiveTarget):
"""Only a tracker the suite owns end to end gets its marker created for it."""
if target.kind != "caldav":
return None
def provision() -> None:
reader = live.reader_for(target.config)
from chemenu.tasks import caldav as cd
cfg = cd.CalDAVConfig.from_section(
settings.Section.from_data(config_shapes.CALDAV, target.config["caldav"]))
writer = cd.CalDAVWriter(cfg, reader)
for name in (live.MARKER_PROJECT, cfg.inbox_list, cfg.someday_list):
if name not in {p.name for p in reader.projects()}:
writer.create_project(name)
return provision
def _open_titles(reader) -> dict[str, str]:
return {item.title: item.id for item in reader.open_items(live.MARKER_PROJECT).items}
@pytest.mark.live_tracker
def test_the_documented_workflow_runs_against_the_real_tracker(
target, kb_dir, tmp_path, monkeypatch, record_property, capsys,
):
root = kb_dir.parent
profile = tmp_path / "live-profile.json"
profile.write_text(json.dumps(target.config), encoding="utf-8")
assert "plain" not in profile.read_text(encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
live.export_secrets(target, monkeypatch.setenv)
with capsys.disabled():
print(f"\n[tracker-live] {target.label} - version {target.version} - run {live.RUN_ID}")
record_property("tracker_kind", target.kind)
record_property("tracker_version", target.version)
reader = live.reader_for(target.config)
live.guard(reader, provision=_provisioner(target))
_project_page(root, live.MARKER_PROJECT)
open_title, waiting_title = live.run_id_titles()
overdue = live.overdue_day(date.today())
try:
created = runner.invoke(app, ["task", "new", "--project", live.MARKER_PROJECT,
"--title", open_title])
assert created.exit_code == 0, created.output
created = runner.invoke(app, ["task", "new", "--project", live.MARKER_PROJECT,
"--title", waiting_title, "--waiting",
"--follow-up-at", overdue.isoformat()])
assert created.exit_code == 0, created.output
listing = runner.invoke(app, ["task", "list", "--project", live.MARKER_PROJECT])
assert listing.exit_code == 0, listing.output
assert open_title in listing.output and waiting_title in listing.output
titles = _open_titles(reader)
assert open_title in titles and waiting_title in titles
waiting = {w.title: w for w in reader.open_items(live.MARKER_PROJECT).waiting}
assert waiting_title in waiting and open_title not in waiting
assert waiting[waiting_title].follow_up_at == overdue
report = run_review(root, today=date.today())
assert any(
f.check == CHECK_WAITING_OVERDUE and f.item_id == titles[waiting_title]
for f in report.findings
), [f for f in report.findings]
for title in (open_title, waiting_title):
closed = runner.invoke(app, ["task", "close", "--id", titles[title]])
assert closed.exit_code == 0, closed.output
assert not {open_title, waiting_title} & set(_open_titles(reader))
finally:
for title, item_id in _open_titles(reader).items():
if title.startswith(live.ITEM_PREFIX):
runner.invoke(app, ["task", "close", "--id", item_id])
# --- the safety rules, against fakes -------------------------------------------------------
class _RecordingReader:
"""A tracker that knows the listed projects and counts every call that could write."""
def __init__(self, names: list[str]):
self._names = names
self.writes = 0
def projects(self):
return [ProjectSummary(name=n, created=None) for n in self._names]
def open_items(self, project_name):
return OpenItems(count=0, waiting=(), items=())
def someday_items(self) -> list[SomedayItem]:
return []
def source(self):
return ReadSource(kind="fake", detail="fake")
def test_guard_aborts_when_the_marker_project_is_missing_and_nothing_was_written():
reader = _RecordingReader(["Inbox", "Somebody's real project"])
with pytest.raises(LiveAbort, match="Chemenu Live-Test"):
live.guard(reader)
assert reader.writes == 0
def test_guard_accepts_the_marker_in_any_case_and_spacing():
live.guard(_RecordingReader([" chemenu LIVE-test "]))
def test_guard_provisions_only_when_a_provisioner_is_given():
names: list[str] = []
reader = _RecordingReader(names)
def provision():
names.append(live.MARKER_PROJECT)
live.guard(reader, provision=provision)
assert names == [live.MARKER_PROJECT]
with pytest.raises(LiveAbort):
live.guard(_RecordingReader([]))
@contextlib.contextmanager
def _answering_health_server():
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
body = json.dumps({"ok": True, "data": {"server": "up", "rendererReady": True}}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # noqa: D401
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server.server_address[1]
finally:
server.shutdown()
thread.join(timeout=5)
def test_start_refuses_when_the_api_port_already_answers(tmp_path):
executable = tmp_path / "superproductivity"
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o755)
with _answering_health_server() as port:
with pytest.raises(LiveAbort, match="already answers"):
sp_headless.start(executable, tmp_path / "profile", marker_project="x", port=port)
assert not (tmp_path / "profile").exists()
def test_a_profile_with_a_home_relative_path_is_refused(tmp_path, monkeypatch):
monkeypatch.setattr(live, "REPO_ROOT", tmp_path)
(tmp_path / live.PROFILE_DIR).mkdir()
(tmp_path / live.PROFILE_DIR / "mine.json").write_text(json.dumps({
"schema": 1, "tasks": {"provider": "superproductivity", **live.THRESHOLDS},
"superproductivity": {"access": "snapshot", "backups_dir": "~/.config/sp/backups"},
}))
with pytest.raises(LiveAbort, match="absolute"):
live.load_profile("mine")
@pytest.mark.parametrize("name", ["", "../escape", "a/b", ".hidden"])
def test_a_profile_name_cannot_leave_the_profile_directory(name):
with pytest.raises(LiveAbort, match="bare profile name"):
live.profile_path(name)
def test_a_profile_is_read_exactly_as_written(tmp_path, monkeypatch):
monkeypatch.setattr(live, "REPO_ROOT", tmp_path)
(tmp_path / live.PROFILE_DIR).mkdir()
(tmp_path / live.PROFILE_DIR / "mine.json").write_text(json.dumps({
"schema": 1, "tasks": {"provider": "superproductivity", **live.THRESHOLDS},
"superproductivity": {"access": "snapshot", "backups_dir": str(tmp_path)},
"live_test_version": "19.1.0",
}))
target = live.load_profile("mine")
assert target.version == "19.1.0"
assert "live_test_version" not in target.config
def test_required_kinds_turn_a_missing_tracker_into_a_failure():
assert live.required_kinds({"CHEMENU_LIVE_REQUIRE": "caldav, sp"}) == {"caldav", "sp"}
assert live.required_kinds({}) == set()
assert live.build_target("caldav", {}) is None
assert live.build_target("sp", {}) is None
assert live.build_target("profile", {}) is None
def test_caldav_target_needs_both_credentials():
with pytest.raises(LiveAbort, match="USER"):
live.caldav_target_from_env({"CHEMENU_LIVE_CALDAV_URL": "http://127.0.0.1:5232/u/"})
target = live.caldav_target_from_env({
"CHEMENU_LIVE_CALDAV_URL": "http://127.0.0.1:5232/u/",
"CHEMENU_LIVE_CALDAV_USER": "u", "CHEMENU_LIVE_CALDAV_PASSWORD": "p",
})
assert target is not None and target.config["tasks"]["provider"] == "caldav"
def test_item_titles_carry_the_run_prefix():
for title in live.run_id_titles():
assert title.startswith(live.ITEM_PREFIX)
assert live.ITEM_PREFIX.startswith("[live-test ")