feat: live tracker suite - WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image (#156)
CI / verify (push) Failing after 2m1s
Release / release (push) Successful in 38s

Files changed:
- .gitea/scripts/start-radicale.sh
- .gitea/sp-live/Dockerfile
- .gitea/sp-live/resolve-version.sh
- .gitea/workflows/ci.yml
- .gitea/workflows/sp-live-image.yml
- .gitea/workflows/tracker-live.yml
- .gitignore
- CHANGES.md
- DEVELOPMENT.md
- INSTALL.md
- VERSION
- instructions/dev/doc-pull-through.md
- instructions/dev/stack-dev/SKILL.md
- instructions/dev/testing-conventions.md
- instructions/dev/tracker-testing.md
- kb/gtd/INDEX.md
- kb/gtd/technik/Chemenu 8.0.0 freigeben.md
- kb/gtd/technik/Windows nativ unterstützen.md
- kb/index.md
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/config.py
- tools/chemenu/tasks/config.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/fixtures/sp/MANIFEST.json
- tools/chemenu/tests/fixtures/sp/api/health.json
- tools/chemenu/tests/fixtures/sp/api/projects.json
- tools/chemenu/tests/fixtures/sp/api/tags.json
- tools/chemenu/tests/fixtures/sp/api/tasks.json
- tools/chemenu/tests/fixtures/sp/seed-backup.json
- tools/chemenu/tests/record_sp_fixtures.py
- tools/chemenu/tests/sp_headless.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_sp_recorded.py
- tools/chemenu/tests/test_task_cmd.py
- tools/chemenu/tests/test_tasks_config.py
- tools/chemenu/tests/test_tracker_live.py
- tools/chemenu/tests/tracker_live.py
- tools/pytest.ini
This commit is contained in:
torben committed 2026-09-30 13:23:24 +02:00
1 parent 529793b255
commit b0c64772cc
43 files changed
+2279 -32

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
# Start a throwaway Radicale (a small CalDAV server) for the live tracker suite (Gitea #156).
#
# start-radicale.sh <python-with-radicale> <work-dir>
#
# Radicale runs as a background process, not a service container: the jobs that call this
# already run inside a job container, and a process on 127.0.0.1 needs no network wiring.
# Appends the CHEMENU_LIVE_CALDAV_* variables to $GITHUB_ENV, so the pytest step that follows
# runs the CalDAV half of the live suite - with CHEMENU_LIVE_REQUIRE=caldav set by the
# workflow, so a server that did not come up fails the run instead of skipping it.
set -eu
python="$1"
work="$2"
mkdir -p "$work"
printf 'ci:ci-live-secret\n' > "$work/users"
cat > "$work/config" <<CONF
[server]
hosts = 127.0.0.1:5232
[auth]
type = htpasswd
htpasswd_filename = $work/users
htpasswd_encryption = plain
[storage]
filesystem_folder = $work/data
CONF
"$python" -m radicale --config "$work/config" > "$work/radicale.log" 2>&1 &
i=0
until curl -fsS -o /dev/null -u ci:ci-live-secret -X PROPFIND -H 'Depth: 0' http://127.0.0.1:5232/ci/; do
i=$((i + 1))
if [ "$i" -gt 30 ]; then
echo "radicale did not come up:" >&2
cat "$work/radicale.log" >&2
exit 1
fi
sleep 1
done
version="$("$python" -m radicale --version 2>/dev/null | tail -n 1)"
{
echo "CHEMENU_LIVE_CALDAV_URL=http://127.0.0.1:5232/ci/"
echo "CHEMENU_LIVE_CALDAV_USER=ci"
echo "CHEMENU_LIVE_CALDAV_PASSWORD=ci-live-secret"
echo "CHEMENU_LIVE_CALDAV_VERSION=radicale $version"
} >> "${GITHUB_ENV:?start-radicale.sh runs inside a workflow step}"
echo "radicale $version is up"
+30
View File
@@ -0,0 +1,30 @@
# The image the nightly `tracker-live` workflow runs in: the packaged Super Productivity
# desktop app, an X server to hold it, and what the suite itself needs (Gitea #156).
# Built by `.gitea/workflows/sp-live-image.yml`, never by hand.
FROM debian:trixie-slim
ARG SP_VERSION
ARG SP_SHA512
# `nodejs` is for act_runner, which executes JavaScript actions (checkout) inside the
# job container. `libasound2t64` and `libgbm1` are the two libraries the .deb does not
# pull in and the app will not start without.
RUN set -eu; \
test -n "$SP_VERSION" && test -n "$SP_SHA512"; \
apt-get update -qq; \
apt-get install -y --no-install-recommends \
ca-certificates curl git nodejs python3 python3-venv ripgrep \
xvfb xauth libasound2t64 libgbm1; \
curl -fsSL -o /tmp/sp.deb \
"https://github.com/super-productivity/super-productivity/releases/download/v${SP_VERSION}/superProductivity-amd64.deb"; \
expected="$(printf '%s' "$SP_SHA512" | base64 -d | od -An -v -tx1 | tr -d ' \n')"; \
echo "${expected} /tmp/sp.deb" | sha512sum -c -; \
apt-get install -y --no-install-recommends /tmp/sp.deb; \
rm -rf /tmp/sp.deb /var/lib/apt/lists/*
LABEL org.opencontainers.image.title="chemenu-sp-live" \
org.opencontainers.image.description="Packaged Super Productivity for chemenu's live tracker suite" \
chemenu.sp-version="${SP_VERSION}"
ENV CHEMENU_LIVE_SP_BINARY="/opt/Super Productivity/superproductivity" \
CHEMENU_LIVE_SP_VERSION="${SP_VERSION}"
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# Which Super Productivity release, and the sha512 of its .deb, straight from the
# update channel the desktop clients themselves follow (`latest-linux.yml`).
#
# resolve-version.sh the newest release
# resolve-version.sh 19.1.0 that release
#
# Prints two lines, `version=<x>` and `sha512=<base64>`, so a workflow can append the
# output to $GITHUB_OUTPUT as it is. Gitea #156: the test image follows the channel
# rather than a pin, because installed apps update on their own and a pinned old
# version would be tested against while users run the new one.
set -eu
base=https://github.com/super-productivity/super-productivity/releases
if [ "${1:-latest}" = latest ]; then
url="$base/latest/download/latest-linux.yml"
else
url="$base/download/v$1/latest-linux.yml"
fi
yml="$(curl -fsSL "$url")"
version="$(printf '%s\n' "$yml" | sed -n 's/^version: *//p' | head -n 1)"
sha512="$(printf '%s\n' "$yml" | awk '/url: superProductivity-amd64\.deb/ {found=1; next} found && /sha512:/ {print $2; exit}')"
if [ -z "$version" ] || [ -z "$sha512" ]; then
echo "resolve-version: no version/sha512 for the amd64 .deb in $url" >&2
exit 1
fi
printf 'version=%s\nsha512=%s\n' "$version" "$sha512"
+15
View File
@@ -132,6 +132,21 @@ jobs:
.venv/bin/python -m pytest -q \ .venv/bin/python -m pytest -q \
--cov --cov-report=term --cov-report=xml --cov-report=html --cov --cov-report=term --cov-report=xml --cov-report=html
- name: Live tracker suite (CalDAV)
# The one live tracker that needs no app and no display: a throwaway Radicale on
# loopback. `CHEMENU_LIVE_REQUIRE=caldav` turns "no server" into a failure - without
# it the suite would skip and stay green, which is exactly the outcome this step
# exists to rule out (Gitea #156). The Super Productivity half is nightly, in
# `tracker-live.yml`; see instructions/dev/tracker-testing.md.
env:
CHEMENU_LIVE_REQUIRE: caldav
run: |
set -eu
tools/.venv/bin/pip install --quiet radicale
.gitea/scripts/start-radicale.sh tools/.venv/bin/python /tmp/radicale
cd tools
.venv/bin/python -m pytest -q -m live_tracker -k caldav -s
- name: Coverage report - name: Coverage report
# `always()`: a red suite is exactly when the per-module numbers are # `always()`: a red suite is exactly when the per-module numbers are
# worth reading, and the upload must not disappear with the failure. # worth reading, and the upload must not disappear with the failure.
+139
View File
@@ -0,0 +1,139 @@
# Builds the image the nightly `tracker-live` run executes in: Debian, the packaged Super
# Productivity, a virtual display and the tools the suite needs (Gitea #156). It lives in
# this Gitea instance's registry as `gitea.nehmer.net/torben/chemenu-sp-live`.
#
# The image follows the update channel, not a pin. Installed desktop apps update themselves,
# so a pinned old version would be tested while users already run the new one. Every day this
# workflow asks `latest-linux.yml` (`.gitea/sp-live/resolve-version.sh`) which release is
# current, and builds only when the registry does not hold that tag yet. It also rebuilds once
# a month regardless, so the Debian layers behind the app do not age unnoticed.
#
# Tags: `:<sp-version>` always, `:latest` only when that version is what the channel says.
# A manual run with `sp_version` builds an older release (to reproduce a red night against
# the version it went red on) and therefore never moves `:latest`.
#
# Runner shape follows torben/gitea-mcp, `.gitea/workflows/binford-release.yaml`: the
# `container-builder` label, a remote BuildKit on the runner host, and the registry login from
# 1Password. `OP_SERVICE_ACCOUNT_TOKEN` is a user-level secret that covers `torben/*`.
#
# After the very first push the package has to be linked to this repository once, by hand, in
# the Gitea UI - a step no workflow can do. Until then the image builds and pulls fine; only
# the package page shows no repository.
name: SP live image
on:
schedule:
# 04:10 UTC, an hour after `nightly` and well before `tracker-live` (05:00), so a new
# release is in the registry by the time the suite looks for it.
- cron: '10 4 * * *'
workflow_dispatch:
inputs:
sp_version:
description: 'Super Productivity release to build (default: the current one)'
required: false
force:
description: 'Rebuild even if the tag already exists (true/false)'
required: false
default: 'false'
env:
REGISTRY: gitea.nehmer.net/torben
IMAGE_NAME: chemenu-sp-live
jobs:
build-and-push:
runs-on: container-builder
container:
image: debian:trixie-slim
steps:
- name: Install CI dependencies
# `nodejs` is for act_runner's JavaScript actions, not for us - see ci.yml.
run: |
set -eu
apt-get update -qq
apt-get install -y --no-install-recommends \
git nodejs curl docker-cli docker-buildx ca-certificates iproute2 gawk
- uses: actions/checkout@v7
- name: Resolve the Super Productivity release
id: sp
env:
REQUESTED: ${{ inputs.sp_version }}
run: |
set -eu
channel="$(.gitea/sp-live/resolve-version.sh latest)"
channel_version="$(printf '%s\n' "$channel" | sed -n 's/^version=//p')"
if [ -n "${REQUESTED:-}" ]; then
wanted="$(.gitea/sp-live/resolve-version.sh "$REQUESTED")"
else
wanted="$channel"
fi
{
printf '%s\n' "$wanted"
echo "channel_version=$channel_version"
} >> "$GITHUB_OUTPUT"
printf '%s\n' "$wanted"
- name: Load secrets from 1Password
uses: 1password/load-secrets-action@v2
with:
export-env: true
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
REGISTRY_USER: op://CI-CD/gitea-package-token/username
REGISTRY_PAT: op://CI-CD/gitea-package-token/password
- name: BuildKit setup (remote builder)
run: |
HOST_IP=$(ip route | awk '/default/ { print $3 }')
docker buildx create --name remote-builder --driver remote tcp://$HOST_IP:1234 --use --bootstrap
- name: Log in to the container registry
run: |
echo "$REGISTRY_PAT" | docker login gitea.nehmer.net -u "$REGISTRY_USER" --password-stdin
- name: Decide whether to build
id: decide
env:
SP_VERSION: ${{ steps.sp.outputs.version }}
CHANNEL_VERSION: ${{ steps.sp.outputs.channel_version }}
FORCE: ${{ inputs.force }}
run: |
set -eu
ref="$REGISTRY/$IMAGE_NAME:$SP_VERSION"
build=false
why=""
if [ "${FORCE:-false}" = true ]; then
build=true; why="forced"
elif [ "$(date -u +%d)" = 01 ]; then
build=true; why="monthly rebuild"
elif ! docker buildx imagetools inspect "$ref" > /dev/null 2>&1; then
build=true; why="$ref is not in the registry yet"
fi
tags="$ref"
if [ "$SP_VERSION" = "$CHANNEL_VERSION" ]; then
tags="$tags
$REGISTRY/$IMAGE_NAME:latest"
fi
{
echo "build=$build"
echo "tags<<EOF"
echo "$tags"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "build=$build ${why:+($why)}; tags: $tags"
- name: Build and push
if: steps.decide.outputs.build == 'true'
uses: docker/build-push-action@v6
with:
context: .gitea/sp-live
file: .gitea/sp-live/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.decide.outputs.tags }}
build-args: |
SP_VERSION=${{ steps.sp.outputs.version }}
SP_SHA512=${{ steps.sp.outputs.sha512 }}
+69
View File
@@ -0,0 +1,69 @@
# The live tracker suite, nightly (Gitea #156): the documented `task` and `review` workflow
# against a real Super Productivity and a real CalDAV server, not against fakes.
#
# `ci.yml` already runs the CalDAV half on every push (Radicale is a pip install). This
# workflow adds the half that needs the desktop app, inside the prebuilt
# `chemenu-sp-live` image (`sp-live-image.yml`), and runs both, so one green night covers both
# providers.
#
# Red after a new Super Productivity release is the finding this workflow exists for, not a
# flaky night: `instructions/dev/tracker-testing.md` says what to do with it. An agent that
# touched the Super Productivity surface (`tasks/superproductivity.py`, `tasks/config.py`,
# the fixtures) dispatches it by hand instead of waiting for the clock.
#
# Runner: `linux-docker`, with the image as the job container. The image carries `nodejs`, so
# `actions/checkout` runs; see ci.yml for why that is the workflow's business.
name: Tracker live
on:
schedule:
- cron: '0 5 * * *'
workflow_dispatch:
jobs:
live:
runs-on: linux-docker
container:
image: gitea.nehmer.net/torben/chemenu-sp-live:latest
env:
WIKITOOL_SESSION_ID: tracker-live-${{ github.run_id }}
WIKI_TRACE_DIR: /tmp/wikitool-trace
steps:
- uses: actions/checkout@v7
- name: Which Super Productivity is this
# The image tag says which release it was built for; whether `:latest` was pulled
# fresh or served from the runner's cache is not certain. So the run compares what is
# installed with what the update channel names now, and says so in the log.
run: |
set -eu
installed="$(dpkg-query -W -f='${Version}' superproductivity)"
channel="$(.gitea/sp-live/resolve-version.sh latest | sed -n 's/^version=//p')"
echo "installed: $installed, update channel: $channel"
if [ "$installed" != "$channel" ]; then
echo "::warning::the image carries Super Productivity $installed, the update channel names $channel - this run tests an outdated app (stale runner cache or an image not rebuilt yet)"
fi
- name: Tool environment
run: |
set -eu
git config --global --add safe.directory "$GITHUB_WORKSPACE"
python3 -m venv tools/.venv
tools/.venv/bin/pip install --quiet --upgrade pip
tools/.venv/bin/pip install --quiet -r tools/requirements.txt
tools/.venv/bin/pip install --quiet pytest radicale
- name: Start Radicale
run: .gitea/scripts/start-radicale.sh tools/.venv/bin/python /tmp/radicale
- name: Live tracker suite
# Both kinds are required: a night in which the app or the server was not there must
# fail rather than skip.
env:
CHEMENU_LIVE_REQUIRE: sp,caldav
run: |
set -eu
cd tools
.venv/bin/python -m pytest -q -m live_tracker -s
+7
View File
@@ -141,6 +141,13 @@ npm-debug.log*
# configured; `doctor` reports which. # configured; `doctor` reports which.
/.wikitool-tasks.json /.wikitool-tasks.json
# Live-suite tracker profiles (Gitea #156): one file per tracker of the user's own that the
# live suite may be pointed at (`CHEMENU_LIVE_PROFILE=<name>`,
# instructions/dev/tracker-testing.md). They carry the same credentials as the file above
# and are per-checkout for the same reason. A directory pattern, anchored: nothing in it is
# ever tracked, so no negation has to rescue anything.
/.wikitool-tasks.d/
# Coverage output from `pytest --cov` (see .gitea/workflows/ci.yml). Derived, # Coverage output from `pytest --cov` (see .gitea/workflows/ci.yml). Derived,
# like reports/: recomputable from any commit, and `publish` runs `git add -A`, # like reports/: recomputable from any commit, and `publish` runs `git add -A`,
# so an unignored htmlcov/ would commit itself on the next content publish. # so an unignored htmlcov/ would commit itself on the next content publish.
+39 -1
View File
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
--- ---
## 8.0.0-beta.2 - 2026-09-30 - Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (#162) ## 8.0.0-beta.3 - 2026-09-30 - Live tracker suite: WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image
**Author:** Torben Nehmer **Author:** Torben Nehmer
@@ -79,6 +79,7 @@ concern - readable here, never shipped as something to parse.
- fail() prints the command's ON FAILURE lines on stderr - fail() prints the command's ON FAILURE lines on stderr
- Budget gate and loop-breaker refusals exit without a traceback - Budget gate and loop-breaker refusals exit without a traceback
- Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (#162) - Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (#162)
- Live tracker suite: WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image
**Low impact** **Low impact**
- version bump no longer points at version release in its output - version bump no longer points at version release in its output
@@ -109,6 +110,43 @@ concern - readable here, never shipped as something to parse.
- new_page/type_resolver comments no longer claim only entities declare a layout: - new_page/type_resolver comments no longer claim only entities declare a layout:
<!-- /wikitool:bumps --> <!-- /wikitool:bumps -->
### Live tracker suite: WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image (Gitea #156)
The task-tracker adapters were only ever tested against fakes, which is how #162 stayed hidden.
This adds a suite that runs the documented `task new` / `task list` / `task close` / `review`
workflow against a real tracker, and the machinery to run it on a clock.
- **`WIKITOOL_TASKS_CONFIG`** names the tracker configuration `task`, `review` and `doctor`
read instead of `.wikitool-tasks.json`, so one checkout can be run against several trackers in
turn. A set variable that names no file is an error naming the path - never "no tracker
configured". Listed in INSTALL.md's variable table and in the affected command records.
- **The `live_tracker` suite** (`tests/test_tracker_live.py`, `tests/tracker_live.py`) writes only
into a project named `Chemenu Live-Test`, prefixes every item with the run id, deletes
nothing, and aborts before the first write when the marker project is missing. It skips
without a tracker; `CHEMENU_LIVE_REQUIRE` turns a missing one into a failure. A tracker of
your own is named by `.wikitool-tasks.d/<name>.json` (gitignored, absolute paths only) and
`CHEMENU_LIVE_PROFILE`.
- **Headless Super Productivity** (`tests/sp_headless.py`) starts the packaged app on a seeded
profile, accepts its startup restore dialog over the DevTools protocol and refuses to start
when something already answers on the fixed API port.
- **Recorded fixtures** (`tests/fixtures/sp/`) hold real answers of v19.1.0 and a backup the app
wrote itself; `test_sp_recorded.py` replays them in the default run, and
`tests/record_sp_fixtures.py` records them again.
- **CI.** `ci.yml` runs the CalDAV half against a Radicale process on every push.
`tracker-live.yml` runs both providers nightly inside the `chemenu-sp-live` image, which
`sp-live-image.yml` builds daily when the update channel has a new Super Productivity version
and monthly regardless. The image follows the channel rather than a pin, because installed
desktop clients update themselves.
- **Docs.** `instructions/dev/tracker-testing.md` has the profile procedure per tracker, when an
agent dispatches the nightly run, what a red night means and how to refresh the fixtures;
`testing-conventions.md` names the live suite as the one deliberate exception to the hermetic
default. `docs verify` gained an ignore canary for `.wikitool-tasks.d/`.
- **Demo corpus.** `kb/gtd/technik/` gains two project pages taken from the Windows and release
work, matching projects in the seed backup, so `review` has something to join.
The Docker package `chemenu-sp-live` has to be linked to this repository once, by hand, after the
first image build.
### Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (Gitea #162) ### Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (Gitea #162)
Preparing the live tracker tests (#156) ran the real Super Productivity v19.1.0 headless for the Preparing the live tracker tests (#156) ran the real Super Productivity v19.1.0 headless for the
+10
View File
@@ -94,6 +94,16 @@ führt Testsuite, `docs verify`, `instructions verify` sowie einen vollständige
Nutzer tatsächlich geht. `.gitea/workflows/nightly.yml` ist der Drift-Check gegen die Zeit statt Nutzer tatsächlich geht. `.gitea/workflows/nightly.yml` ist der Drift-Check gegen die Zeit statt
gegen einen Commit. `.gitea/workflows/release.yml` ist Schritt 6 oben. gegen einen Commit. `.gitea/workflows/release.yml` ist Schritt 6 oben.
Drei weitere Workflows tragen die Live-Tests der Tracker-Adapter (Super Productivity, CalDAV):
`ci.yml` führt in einem eigenen Schritt die CalDAV-Hälfte gegen ein Radicale als Prozess aus,
`tracker-live.yml` läuft nachts und deckt beide Anbieter ab, und `sp-live-image.yml` baut täglich
(bei neuer Version) und monatlich (immer) das Image `chemenu-sp-live` mit der jeweils aktuellen
Super-Productivity-Version. Das Image folgt dem Update-Kanal der Desktop-Clients, nicht einer
festen Version. Einmalig nach dem allerersten Push muss das Paket von Hand dem Repo
`torben/chemenu` zugeordnet werden. Was die Suite schreibt, wie man sie gegen einen eigenen
Tracker laufen lässt und was ein roter Lauf bedeutet, steht in
[instructions/dev/tracker-testing.md](instructions/dev/tracker-testing.md).
## Stack-Entwicklung als eigener Sitzungstyp ## Stack-Entwicklung als eigener Sitzungstyp
Der `stack-dev`-Skill (`instructions/dev/`, nur in diesem Ursprungs-Repo vorhanden) fasst die Der `stack-dev`-Skill (`instructions/dev/`, nur in diesem Ursprungs-Repo vorhanden) fasst die
+1
View File
@@ -278,6 +278,7 @@ Ausnahmen (`kb/CONVENTIONS.md`, `kb/*/COLLECTION.md`, `.wikitool-kb.json`) in
| `WIKITOOL_SESSION_ID` | Scopt das Iteration-Budget-Gate auf eine Aufgabe statt auf ein Terminal | Eine vom Harness selbst gesetzte Sitzungs-Variable, wo eine bekannt ist (z. B. `CLAUDE_CODE_SESSION_ID`), sonst die Parent-Process-ID (siehe [instructions/session-setup.md](instructions/session-setup.md)) | | `WIKITOOL_SESSION_ID` | Scopt das Iteration-Budget-Gate auf eine Aufgabe statt auf ein Terminal | Eine vom Harness selbst gesetzte Sitzungs-Variable, wo eine bekannt ist (z. B. `CLAUDE_CODE_SESSION_ID`), sonst die Parent-Process-ID (siehe [instructions/session-setup.md](instructions/session-setup.md)) |
| `WIKITOOL_UPDATE_URL` | Release-Feed, den `version check` abfragt | Wert aus `.wikitool-release.json`, sonst der Feed der Ursprungs-Instanz | | `WIKITOOL_UPDATE_URL` | Release-Feed, den `version check` abfragt | Wert aus `.wikitool-release.json`, sonst der Feed der Ursprungs-Instanz |
| `WIKITOOL_UPDATE_TOKEN` | Gitea-Token für den Release-Feed | keiner - gegen `torben/chemenu` nicht nötig, nur für einen privaten Fork (siehe unten) | | `WIKITOOL_UPDATE_TOKEN` | Gitea-Token für den Release-Feed | keiner - gegen `torben/chemenu` nicht nötig, nur für einen privaten Fork (siehe unten) |
| `WIKITOOL_TASKS_CONFIG` | Pfad zu einer Tracker-Konfiguration, die `task`, `review` und `doctor` statt `.wikitool-tasks.json` lesen - um einen Checkout der Reihe nach gegen mehrere Tracker laufen zu lassen | die `.wikitool-tasks.json` im Repo-Root. Nennt die Variable eine Datei, die es nicht gibt, ist das ein Fehler und nie „kein Tracker konfiguriert" |
| `CHEMENU_ROOT` | Auf welchen Korpus das Paket zeigt - für einen Aufrufer, der nicht im Checkout selbst liegt | der Checkout, in dem das Paket liegt (`tools/wikitool` verhält sich ohne die Variable unverändert) | | `CHEMENU_ROOT` | Auf welchen Korpus das Paket zeigt - für einen Aufrufer, der nicht im Checkout selbst liegt | der Checkout, in dem das Paket liegt (`tools/wikitool` verhält sich ohne die Variable unverändert) |
| `WIKI_TRACE` / `WIKI_TRACE_DIR` | Telemetrie abschalten bzw. aus dem Arbeitsbaum heraus umlenken | Hängt vom Installationsweg ab - siehe unten | | `WIKI_TRACE` / `WIKI_TRACE_DIR` | Telemetrie abschalten bzw. aus dem Arbeitsbaum heraus umlenken | Hängt vom Installationsweg ab - siehe unten |
| `WIKI_TRACE_MAX_SESSION_BYTES` / `WIKI_TRACE_KEEP_SESSIONS` | Byte-Deckel je Session-Trace bzw. wie viele Session-Verzeichnisse die Retention behält | 5 MiB je Session, 250 Verzeichnisse | | `WIKI_TRACE_MAX_SESSION_BYTES` / `WIKI_TRACE_KEEP_SESSIONS` | Byte-Deckel je Session-Trace bzw. wie viele Session-Verzeichnisse die Retention behält | 5 MiB je Session, 250 Verzeichnisse |
+1 -1
View File
@@ -1 +1 @@
8.0.0-beta.2 8.0.0-beta.3
+1
View File
@@ -38,6 +38,7 @@ touched; a row that does not apply needs no action.
| A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) | | A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) |
| A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader | | A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader |
| The reasoning behind a gate, boundary, or design decision | The `docs/` page that carries it, if one exists (AGENTS.md § File naming lists all six reached from AGENTS.md itself, plus a seventh reached only from CLAUDE.md). **A decision with no page yet is the gap worth closing**: reasoning that lives only in a Gitea issue never ships - `dist export` carries `docs/` and no issue tracker, so a distributed instance gets the mechanism without the why | | The reasoning behind a gate, boundary, or design decision | The `docs/` page that carries it, if one exists (AGENTS.md § File naming lists all six reached from AGENTS.md itself, plus a seventh reached only from CLAUDE.md). **A decision with no page yet is the gap worth closing**: reasoning that lives only in a Gitea issue never ships - `dist export` carries `docs/` and no issue tracker, so a distributed instance gets the mechanism without the why |
| A task-tracker adapter (`tools/chemenu/tasks/`), its recorded fixtures, or the live suite | [instructions/dev/tracker-testing.md](tracker-testing.md), and `MANIFEST.json` beside the fixtures when they were re-recorded |
| A skill's own step sequence or catalogue | The skill's `SKILL.md` source under `instructions/<name>/` or `instructions/dev/<name>/` | | A skill's own step sequence or catalogue | The skill's `SKILL.md` source under `instructions/<name>/` or `instructions/dev/<name>/` |
| A per-checkout configuration file an instance owns (`.wikitool-tasks.json`, `.wikitool-telemetry.json`, `.wikitool-remotes.json`, `.wikitool-upload.json`) | [INSTALL.md](../../INSTALL.md) § Konfiguration, where an operator looks the shape up; the [setup-instance.md](../setup-instance.md) decision point that offers it during setup; and `doctor`'s own row in [tools/CONTRACT.md](../../tools/CONTRACT.md), since `doctor` is what reports the file's state | | A per-checkout configuration file an instance owns (`.wikitool-tasks.json`, `.wikitool-telemetry.json`, `.wikitool-remotes.json`, `.wikitool-upload.json`) | [INSTALL.md](../../INSTALL.md) § Konfiguration, where an operator looks the shape up; the [setup-instance.md](../setup-instance.md) decision point that offers it during setup; and `doctor`'s own row in [tools/CONTRACT.md](../../tools/CONTRACT.md), since `doctor` is what reports the file's state |
| A new page type the stack requires, or a new collection | Its type-spec and `COLLECTION.md` (both as the `.template` an instance adopts), the collection table in [kb/CONTRACT.md](../../kb/CONTRACT.md), and **both adoption paths**: [setup-instance.md](../setup-instance.md) for a fresh instance and [upgrade-instance.md](../upgrade-instance.md) for an existing one, where an unadopted template is what `docs verify` refuses | | A new page type the stack requires, or a new collection | Its type-spec and `COLLECTION.md` (both as the `.template` an instance adopts), the collection table in [kb/CONTRACT.md](../../kb/CONTRACT.md), and **both adoption paths**: [setup-instance.md](../setup-instance.md) for a fresh instance and [upgrade-instance.md](../upgrade-instance.md) for an existing one, where an unadopted template is what `docs verify` refuses |
+5
View File
@@ -55,6 +55,11 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
`instructions/dev/testing-conventions.md` - the suite runs against a deliberately `instructions/dev/testing-conventions.md` - the suite runs against a deliberately
empty machine; what the autouse fixture already neutralizes, and what a test still has to empty machine; what the autouse fixture already neutralizes, and what a test still has to
establish itself. Read it before adding or changing a test. establish itself. Read it before adding or changing a test.
`instructions/dev/tracker-testing.md` - how the task-tracker adapters are tested against a
real Super Productivity and CalDAV server: the `live_tracker` suite, the profile procedure for
a tracker of your own, the nightly workflow (which you dispatch yourself after touching the
Super Productivity surface), what a red night means, and refreshing the recorded fixtures.
Read it before changing an adapter under `tools/chemenu/tasks/`.
`instructions/dev/version-parts.md` - which part a change bumps: the drop-in test, the `instructions/dev/version-parts.md` - which part a change bumps: the drop-in test, the
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
put in front of the user before a breaking bump. Read it before step 4. put in front of the user before a breaking bump. Read it before step 4.
+5 -1
View File
@@ -130,7 +130,7 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
fixture creates that machine. fixture creates that machine.
4. **Adding a new environment variable to the tool?** Add it to `_WIKITOOL_ENV` in 4. **Adding a new environment variable to the tool?** Add it to `_WIKITOOL_ENV` in
`conftest.py` in the same change. A variable the tool reads and the fixture does not clear `conftest.py` in the same change (`WIKITOOL_TASKS_CONFIG` is one). A variable the tool reads and the fixture does not clear
is the exact hole this whole file is about, reopened. is the exact hole this whole file is about, reopened.
5. **Writing a fixture that builds a tree?** Repoint `config.ROOT` at it and call 5. **Writing a fixture that builds a tree?** Repoint `config.ROOT` at it and call
@@ -168,6 +168,10 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
- **A test genuinely needs the developer's real environment?** There is no such test, and a new - **A test genuinely needs the developer's real environment?** There is no such test, and a new
one is a design problem rather than an exception: what it wants is a fixture that *builds* one is a design problem rather than an exception: what it wants is a fixture that *builds*
the state it needs inside `tmp_path`. Building it is also the only version CI can run. the state it needs inside `tmp_path`. Building it is also the only version CI can run.
The one deliberate exception is the `live_tracker` suite, which talks to a real tracker and
may start a real application; it is opt-in through `CHEMENU_LIVE_*` variables, skips without
them, and the default run needs neither network nor tracker. Its rules:
[tracker-testing.md](tracker-testing.md).
- **A test patches `config.default_author` directly** (as - **A test patches `config.default_author` directly** (as
`test_new_source_fails_hard_without_any_author` does)? Keep the patch. It is not made `test_new_source_fails_hard_without_any_author` does)? Keep the patch. It is not made
redundant by the fixture - it pins the value under test regardless of what the environment redundant by the fixture - it pins the value under test regardless of what the environment
+208
View File
@@ -0,0 +1,208 @@
---
type: types/instruction.md
name: tracker-testing
description: How the task-tracker adapters (Super Productivity, CalDAV) are tested against a real tracker - the live suite, the profile procedure for a tracker of your own, the nightly workflow and when an agent triggers it, what a red night means, and how to refresh the recorded fixtures.
---
# Test a tracker adapter against a real tracker
The default `pytest` run never talks to a tracker: it reads recorded answers
(`tools/chemenu/tests/fixtures/sp/`) and fakes. That is enough to keep the parsing honest and
not enough to know that the adapter still works against the tracker as it ships today - a
tracker is software somebody else releases (Gitea #156, and the adapter defects of #162 that
only a real Super Productivity could show). So a second suite exists, marked `live_tracker`,
that runs the documented `task new` / `task list` / `task close` / `review` workflow end to end.
It skips when no tracker is configured and fails when CI says one must be there.
<!-- wikitool:toc -->
## Contents
- [The three ways to name a tracker](#the-three-ways-to-name-a-tracker)
- [What the suite writes, and what it never does](#what-the-suite-writes-and-what-it-never-does)
- [Run it against a tracker of your own](#run-it-against-a-tracker-of-your-own)
- [The two workflows and the image](#the-two-workflows-and-the-image)
- [When an agent triggers the nightly run](#when-an-agent-triggers-the-nightly-run)
- [A red night](#a-red-night)
- [Refreshing the fixtures](#refreshing-the-fixtures)
- [Steps](#steps)
- [Scope](#scope)
<!-- /wikitool:toc -->
## The three ways to name a tracker
All through the environment; none of them is a fixed address, and none of them is read by a
plain `pytest` run unless you set it.
| Variable | Tracker | Who owns it |
|---|---|---|
| `CHEMENU_LIVE_SP_BINARY` | The packaged Super Productivity (`.deb` layout). The suite starts its own headless copy on a fresh profile, seeded from `fixtures/sp/seed-backup.json`, and stops it again. | The suite |
| `CHEMENU_LIVE_CALDAV_URL`, `_USER`, `_PASSWORD` (optional `_VERSION` for the report line) | A CalDAV collection. In CI it is a throwaway Radicale started by `.gitea/scripts/start-radicale.sh`. | The suite - it may create the marker calendar |
| `CHEMENU_LIVE_PROFILE=<name>` | A tracker of your own, described by `.wikitool-tasks.d/<name>.json` in the checkout, used exactly as configured. | You - the suite never creates anything but items |
`CHEMENU_LIVE_REQUIRE=sp,caldav,profile` (any subset) turns "no such tracker configured" into
a failure instead of a skip. CI sets it; without it a workflow that lost its server would go
green by skipping, which is the outcome the suite exists to rule out.
The commands under test read their configuration from `.wikitool-tasks.json`, or from the file
`WIKITOOL_TASKS_CONFIG` names when that variable is set. The live suite sets the variable per
test, so it never touches the checkout's own `.wikitool-tasks.json`; you can use the same
variable to run `task` and `review` by hand against a second tracker.
## What the suite writes, and what it never does
The safety rules are code (`tools/chemenu/tests/tracker_live.py`), and
`test_tracker_live.py` proves them against fakes on every run - they are not a promise in this
file.
- It writes only into the project named **`Chemenu Live-Test`** (`MARKER_PROJECT`). The project
must already exist. The suite creates it only in a tracker it owns end to end (the CalDAV
server it started); in a profile target, or in Super Productivity, a missing marker is an
abort before the first write - Super Productivity's API has no way to create a project anyway.
- Every item it creates starts with `[live-test <run id>]`. On the way out it closes what is
left of that prefix, and only that prefix.
- It never deletes anything. The only closing write is `task close`, the one this stack has.
- The headless Super Productivity refuses to start when anything already answers on the fixed
API port 3876: an answering app is somebody's real one.
- A profile path that starts with `~` is refused. Profiles carry absolute paths, because a `~`
resolves against whichever `HOME` the run has.
## Run it against a tracker of your own
Do this before you change an adapter for a tracker you actually use, or when a user reports one
that fails against theirs. The steps differ per tracker only in the profile and the marker.
1. **Create the marker project in that tracker**, by hand, named exactly `Chemenu Live-Test`.
Nothing else in the tracker is touched, but this project's items are created and closed.
2. **Write the profile** to `.wikitool-tasks.d/<name>.json` (gitignored - it holds
credentials). It is a normal `.wikitool-tasks.json` plus one optional key,
`live_test_version`, which is stripped before use and only names the tracker's version in
the report line. Paths are absolute.
Super Productivity, API access (the app must be running with the Local REST API on):
```json
{
"schema": 1,
"provider": "superproductivity",
"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5},
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:3876", "api_token": "<token>"},
"live_test_version": "19.1.0"
}
```
CalDAV:
```json
{
"schema": 1,
"provider": "caldav",
"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5},
"caldav": {"url": "https://<server>/<user>/", "username": "<user>", "app_password": "<password>",
"inbox_list": "Inbox", "someday_list": "Someday"},
"live_test_version": "<server and version>"
}
```
A Super Productivity profile with `access: "snapshot"` is accepted too, but the workflow
needs a write path, so the scenario fails on it by design: that access is read-only.
3. **Run it**, from `tools/`:
```bash
CHEMENU_LIVE_PROFILE=<name> CHEMENU_LIVE_REQUIRE=profile \
.venv/bin/python -m pytest -m live_tracker -k profile -s
```
`-s` shows the report line with the tracker version, which is what you paste into an issue.
4. **Read a failure as a finding about that tracker**, not as a flaky test: the scenario is
deterministic. Reproduce it with `task` and `review` under `WIKITOOL_TASKS_CONFIG`, then fix
the adapter and add a recorded fixture for the answer that broke it.
## The two workflows and the image
| Workflow | When | What |
|---|---|---|
| `ci.yml`, step "Live tracker suite (CalDAV)" | Every push and PR | Radicale as a process on loopback (`pip install radicale`), `CHEMENU_LIVE_REQUIRE=caldav`. The one half that needs no app and no display. |
| `tracker-live.yml` | Nightly 05:00 UTC, and by hand | Both kinds, `CHEMENU_LIVE_REQUIRE=sp,caldav`, inside the `chemenu-sp-live` image. It logs the installed Super Productivity version and warns when it differs from the update channel. |
| `sp-live-image.yml` | Daily 04:10 UTC, and by hand | Builds `gitea.nehmer.net/torben/chemenu-sp-live` when the registry lacks the channel's current version, and once a month regardless. |
The image follows the update channel (`latest-linux.yml`, resolved by
`.gitea/sp-live/resolve-version.sh`) and not a pin: installed desktop clients update
themselves, and a pinned old version would be tested while users run the new one. Tags are
`:<version>` and `:latest`; a manual build with an explicit `sp_version` never moves `:latest`,
which is how you reproduce a red night against the version it went red on.
**The first push of the package needs one manual step**, which no workflow can do: link the
package `chemenu-sp-live` to the repository `torben/chemenu` in the Gitea UI, once, and
dispatch `sp-live-image.yml` first - `tracker-live.yml` has nothing to pull before that. The
package is public, so the runner pulls it anonymously.
## When an agent triggers the nightly run
A session that changed the Super Productivity surface does not wait for the clock: after the
push has landed and CI is green, dispatch `tracker-live.yml` and poll it (Gitea MCP,
`actions_run_write` then `actions_run_read`; never an anonymous `curl`). The surface is
`tools/chemenu/tasks/superproductivity.py`, `tools/chemenu/tasks/config.py`,
`tools/chemenu/tests/sp_headless.py`, `tools/chemenu/tests/tracker_live.py`, the fixtures under
`tools/chemenu/tests/fixtures/sp/`, and `.gitea/sp-live/`. A change to the CalDAV adapter needs
no dispatch - `ci.yml` already ran it.
Say in the session summary which run answered and against which Super Productivity version.
## A red night
The nightly run is red for one of three reasons, and they are told apart by the log:
1. **The app changed.** The report line names a Super Productivity version newer than the last
green night. This is the finding the workflow exists for: the adapter must follow. Re-record
the fixtures (below), fix the adapter, and open an issue for it with the two version
numbers.
2. **The image is stale.** The step "Which Super Productivity is this" warned that the
installed version differs from the channel. Dispatch `sp-live-image.yml` with `force`, then
the run again.
3. **The environment.** The app did not start (the abort message carries the last lines of
the app's own log), or the runner could not pull the image. Nothing about the adapter is known yet.
Never skip the suite, never remove `CHEMENU_LIVE_REQUIRE` from a workflow and never mark the
job `continue-on-error` to get a green night: a red night with a cause is the product.
## Refreshing the fixtures
`tools/chemenu/tests/fixtures/sp/api/*.json` are the raw answers of a real Super Productivity;
`seed-backup.json` is a backup the app wrote itself, with the three projects the live suite and
the demo pages use. `MANIFEST.json` says which version and when. Refresh them when a red night
shows a new answer shape, and when the manifest's version is more than a few releases behind.
1. Get a Super Productivity to record from: the current `chemenu-sp-live` image with the
checkout mounted (`docker run --rm -v "$PWD:/work" -w /work/tools ...`, then a venv from
`requirements.txt` inside it), or any machine with the `.deb` installed and
`CHEMENU_LIVE_SP_BINARY` set.
2. From `tools/`, in an environment that has `requirements.txt`:
```bash
.venv/bin/python -m chemenu.tests.record_sp_fixtures chemenu/tests/fixtures/sp/api
```
3. Update `MANIFEST.json` (`sp_version`, `recorded`) by hand - it is a fixture, not a generated
page - and run `test_sp_recorded.py`. If the seed backup is stale because the app's backup
format moved, rebuild it from a backup the new version wrote and keep the three projects.
4. Do not edit the recorded answers to make a test pass. A test that fails against a fresh
recording is the adapter's problem.
## Steps
1. **Decide which kind of change this is.** A change to an adapter's parsing: extend the recorded
fixtures and their replay test first. A change that alters what the adapter sends: the live
suite has to run, so run it against a tracker (above) before publishing.
2. **Run the offline half** with the rest of the suite; it includes the safety tests for the
guard, the profile rules and the port refusal.
3. **Run the live half** for the tracker you changed - CalDAV locally with a Radicale of your
own, Super Productivity in the image or with a profile.
4. **After the push, dispatch `tracker-live.yml`** when the change is on the Super Productivity
surface (see [When an agent triggers the nightly run](#when-an-agent-triggers-the-nightly-run)).
## Scope
Applies to the task-tracker adapters under `tools/chemenu/tasks/` and to the live suite
itself. It is the one place the test conventions are relaxed on purpose:
[testing-conventions.md](testing-conventions.md) describes the hermetic default run, and the
live suite is opt-in, marked `live_tracker`, and may start a real application and talk to a
real server. The default run stays free of both.
+8 -1
View File
@@ -2,5 +2,12 @@
# kb/gtd/ - Index # kb/gtd/ - Index
0 page(s). Regenerated by `wikitool index rebuild`. 2 page(s). Regenerated by `wikitool index rebuild`.
## Technik
| Page | Type | Summary | Last Modified |
|------|------|---------|----------------|
| [[Chemenu 8.0.0 freigeben]] | technik | Die Beta-Reihe 8.0.0 wird zum Release gemacht; offen ist, was zwischen letztem Kandidaten und Freigabe noch geklärt sein muss. | 2026-09-30 |
| [[Windows nativ unterstützen]] | technik | Chemenu läuft auf Windows nativ unter PowerShell 7, ohne WSL und ohne Windows PowerShell 5.1. | 2026-09-30 |
+46
View File
@@ -0,0 +1,46 @@
---
type: types/project.md
state: active
responsibility: technik
created: 2026-09-30
modified: 2026-09-30
related:
- see-also: Chemenu
sources: []
provenance: general
summary: Aus dem laufenden Kandidaten 8.0.0-beta wird das Release 8.0.0.
---
# Chemenu 8.0.0 freigeben
**Status:** Active
**Bereich:** Technik
## Ziel
Aus dem laufenden Kandidaten 8.0.0-beta wird das Release 8.0.0: veröffentlicht, mit vollständigem Changelog-Eintrag, und eine bestehende Instanz kann per `dist upgrade` darauf wechseln.
## Kontext
Zwischen zwei Releases führt [[Chemenu]] als Stack einen einzigen laufenden Kandidaten mit `-beta.N`-Suffix statt einer neuen Version je Änderung. Der Schritt vom Kandidaten zum Release ist bewusst eine menschliche Entscheidung und kein Agentenschritt.
## Beteiligte
Torben Nehmer gibt frei; ein Agent bereitet die Kandidaten vor und schreibt den Changelog.
## Status
Der Kandidat trägt die Änderungen der Installationsüberarbeitung und der Tracker-Anbindung. Ein Release ist noch nicht gefallen.
## Entscheidungen
- Die Freigabe selbst (`version release`) führt kein Agent aus.
## Gelerntes
Ein Test gegen aufgezeichnete Antworten hält den Parser ehrlich, sagt aber nichts darüber, ob der Tracker heute noch so antwortet - dafür braucht es einen Lauf gegen das echte Programm.
<!-- wikitool:links -->
## Beziehungen
- **see-also:** [[Chemenu]]
<!-- /wikitool:links -->
@@ -0,0 +1,48 @@
---
type: types/project.md
state: active
responsibility: technik
created: 2026-09-30
modified: 2026-09-30
related:
- see-also: Chemenu
sources: []
provenance: general
summary: Chemenu läuft auf Windows nativ unter PowerShell 7, ohne WSL und ohne Windows PowerShell 5.1.
---
# Windows nativ unterstützen
**Status:** Active
**Bereich:** Technik
## Ziel
Ein Windows-Nutzer installiert und betreibt Chemenu nativ - unter PowerShell 7, ohne WSL und ohne Windows PowerShell 5.1 - und der Installationsweg führt ihn ohne Handarbeit an der Distribution selbst zur laufenden Instanz.
## Kontext
Die Installation von [[Chemenu]] war bisher auf Linux und macOS zugeschnitten. Mit der Überarbeitung der Installation (Gitea-Issue 140) wird Windows kein Sonderfall am Rand mehr, sondern ein gleichberechtigter Zielpfad. Als Installationsweg bleibt der Release-Download (Weg A) übrig.
## Beteiligte
Torben Nehmer entscheidet über Umfang und Reihenfolge.
## Status
Der Zuschnitt ist entschieden: PowerShell 7 ist die Untergrenze, WSL und Windows PowerShell 5.1 sind ausdrücklich ausgeschlossen. Vor dem Setup prüft ein Preflight die Voraussetzungen; fehlt eine, bricht er mit Exit-Code 42 ab und der Nutzer sieht die Ausgabe, bevor irgendetwas weiterläuft.
## Entscheidungen
- Nur Weg A, der Release-Download - eine zweite Windows-Sonderbahn wäre eine zweite Kopie der Regeln.
- Kein Agent installiert Abhängigkeiten: was fehlt, meldet der Preflight, und der Nutzer entscheidet.
- PowerShell 7 ist die einzige unterstützte Shell; Windows PowerShell 5.1 und WSL sind kein Zielpfad.
## Gelerntes
Pfade, die ein Test auf einem Linux-Rechner nie sieht - Laufwerksbuchstaben, Groß- und Kleinschreibung, Zeichen, die in einem Dateinamen verboten sind - fallen erst auf, wenn die Regel dafür im Werkzeug selbst steht. Seitentitel müssen deshalb als Dateinamen auf Windows und macOS gültig sein.
<!-- wikitool:links -->
## Beziehungen
- **see-also:** [[Chemenu]]
<!-- /wikitool:links -->
+10 -4
View File
@@ -13,13 +13,13 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
## Statistics ## Statistics
- **Total Pages:** 182 - **Total Pages:** 184
- **Comparisons:** 1 - **Comparisons:** 1
- **Concepts:** 80 - **Concepts:** 80
- **Entities:** 72 - **Entities:** 72
- **Gtd:** 0 - **Gtd:** 2
- **Sources:** 29 - **Sources:** 29
- **Last Updated:** 2026-09-26 - **Last Updated:** 2026-09-30
--- ---
@@ -30,7 +30,7 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
| `comparisons/` | 1 | [comparisons/INDEX.md](comparisons/INDEX.md) | | `comparisons/` | 1 | [comparisons/INDEX.md](comparisons/INDEX.md) |
| `concepts/` | 80 | [concepts/INDEX.md](concepts/INDEX.md) | | `concepts/` | 80 | [concepts/INDEX.md](concepts/INDEX.md) |
| `entities/` | 72 | [entities/INDEX.md](entities/INDEX.md) | | `entities/` | 72 | [entities/INDEX.md](entities/INDEX.md) |
| `gtd/` | 0 | [gtd/INDEX.md](gtd/INDEX.md) | | `gtd/` | 2 | [gtd/INDEX.md](gtd/INDEX.md) |
| `sources/` | 29 | [sources/INDEX.md](sources/INDEX.md) | | `sources/` | 29 | [sources/INDEX.md](sources/INDEX.md) |
### concepts/ ### concepts/
@@ -54,6 +54,12 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
| Technologien | 20 | [entities/INDEX.md#technologien](entities/INDEX.md#technologien) | | Technologien | 20 | [entities/INDEX.md#technologien](entities/INDEX.md#technologien) |
| Werkzeuge | 31 | [entities/INDEX.md#werkzeuge](entities/INDEX.md#werkzeuge) | | Werkzeuge | 31 | [entities/INDEX.md#werkzeuge](entities/INDEX.md#werkzeuge) |
### gtd/
| Area | Pages | Index |
|------|------:|-------|
| Technik | 2 | [gtd/INDEX.md#technik](gtd/INDEX.md#technik) |
### sources/ ### sources/
| Area | Pages | Index | | Area | Pages | Index |
+13 -2
View File
@@ -254,6 +254,7 @@ Create one open item in the configured task tracker - never a kb/ page.
- 0 success - 0 success
- 1 No `.wikitool-tasks.json` - no tracker configured - 1 No `.wikitool-tasks.json` - no tracker configured
- 1 `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken
- 1 Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD` - 1 Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD`
- 1 A `--project` name matching no tracker project - 1 A `--project` name matching no tracker project
- 1 `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist) - 1 `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist)
@@ -262,6 +263,7 @@ Create one open item in the configured task tracker - never a kb/ page.
**ON FAILURE** **ON FAILURE**
- No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first - No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first
- `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken -> Not transient - fix the path or unset the variable
- Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD` -> Fix the argument and retry once - Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD` -> Fix the argument and retry once
- A `--project` name matching no tracker project -> Create the tracker project first, or fix the name, then retry once - A `--project` name matching no tracker project -> Create the tracker project first, or fix the name, then retry once
- `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist) -> Create the tag in the tracker first, then retry once - `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist) -> Create the tag in the tracker first, then retry once
@@ -279,6 +281,7 @@ Create one open item in the configured task tracker - never a kb/ page.
- `--inbox` files into the tracker's own inbox. An item filed there never appears in `review`, since every one of its checks reaches items through a project name. - `--inbox` files into the tracker's own inbox. An item filed there never appears in `review`, since every one of its checks reaches items through a project name.
- `--waiting` sets the WAITING status `review`'s waiting-overdue check reads. `--follow-up-at` is refused without `--waiting` - it is never a due date on its own. - `--waiting` sets the WAITING status `review`'s waiting-overdue check reads. `--follow-up-at` is refused without `--waiting` - it is never a due date on its own.
- `--notes` carries a freetext backref (e.g. to the `kb/` source page the item came from), stored verbatim, never parsed. - `--notes` carries a freetext backref (e.g. to the `kb/` source page the item came from), stored verbatim, never parsed.
- The tracker configuration is read from `.wikitool-tasks.json`, or from the file `WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be run against several trackers in turn). A set variable that names no file is an error, never "no tracker configured".
- No `.wikitool-tasks.json` fails immediately with a "no tracker configured" message. - No `.wikitool-tasks.json` fails immediately with a "no tracker configured" message.
- A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses entirely with exit 1, naming the `access: "api"` instance to use instead. - A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses entirely with exit 1, naming the `access: "api"` instance to use instead.
- **Never exits 42.** A `--project` matching no tracker project, or `--waiting` against a provider that cannot represent it right now (Super Productivity: the `waiting` tag does not exist yet, and its API cannot create tags), are ordinary exit-1 refusals that create nothing. - **Never exits 42.** A `--project` matching no tracker project, or `--waiting` against a provider that cannot represent it right now (Super Productivity: the `waiting` tag does not exist yet, and its API cannot create tags), are ordinary exit-1 refusals that create nothing.
@@ -316,10 +319,12 @@ List a project's open items - id, title, and whether each carries the WAITING st
- 0 success - 0 success
- 0 A `--project` matching no tracker project - prints "No open items", not an error - 0 A `--project` matching no tracker project - prints "No open items", not an error
- 1 No `.wikitool-tasks.json` - no tracker configured - 1 No `.wikitool-tasks.json` - no tracker configured
- 1 `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken
**ON FAILURE** **ON FAILURE**
- No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first, then retry once - No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first, then retry once
- `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken -> Not transient - fix the path or unset the variable
**NOTES** **NOTES**
@@ -358,12 +363,14 @@ Mark one tracker item done - never delete it.
- 0 success - 0 success
- 1 No `.wikitool-tasks.json` - no tracker configured - 1 No `.wikitool-tasks.json` - no tracker configured
- 1 `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken
- 1 An `--id` matching no tracker item right now - 1 An `--id` matching no tracker item right now
- 1 A read-only access path (Super Productivity's `access: "snapshot"`) - 1 A read-only access path (Super Productivity's `access: "snapshot"`)
**ON FAILURE** **ON FAILURE**
- No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first - No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first
- `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken -> Not transient - fix the path or unset the variable
- An `--id` matching no tracker item right now -> Get a current id from `task list` or `review`, then retry once - An `--id` matching no tracker item right now -> Get a current id from `task list` or `review`, then retry once
- A read-only access path (Super Productivity's `access: "snapshot"`) -> Point at the `access: "api"` instance the error names, then retry once - A read-only access path (Super Productivity's `access: "snapshot"`) -> Point at the `access: "api"` instance the error names, then retry once
@@ -375,6 +382,7 @@ Mark one tracker item done - never delete it.
- Marks one tracker item done. It never deletes or moves an item - the only closing write this stack makes. - Marks one tracker item done. It never deletes or moves an item - the only closing write this stack makes.
- `--id` is the provider's own item id, from `task list` or a `review` finding - never a title. - `--id` is the provider's own item id, from `task list` or a `review` finding - never a title.
- The tracker configuration is read from `.wikitool-tasks.json`, or from the file `WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be run against several trackers in turn). A set variable that names no file is an error, never "no tracker configured".
- No `.wikitool-tasks.json` fails with a "no tracker configured" message. - No `.wikitool-tasks.json` fails with a "no tracker configured" message.
- A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses entirely with exit 1, naming the `access: "api"` instance to use instead. - A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses entirely with exit 1, naming the `access: "api"` instance to use instead.
- **Never exits 42.** - **Never exits 42.**
@@ -1204,11 +1212,13 @@ The GTD weekly review.
- 0 success - 0 success
- 1 No `.wikitool-tasks.json`, or a malformed one - a clear "no tracker configured" message - 1 No `.wikitool-tasks.json`, or a malformed one - a clear "no tracker configured" message
- 1 `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken
- 1 The provider was reachable at config-parse time but a read call failed mid-run; the full report (findings plus which checks ran) was printed first - 1 The provider was reachable at config-parse time but a read call failed mid-run; the full report (findings plus which checks ran) was printed first
**ON FAILURE** **ON FAILURE**
- No `.wikitool-tasks.json`, or a malformed one - a clear "no tracker configured" message -> Not fixed by retrying unchanged - configure or repair `.wikitool-tasks.json` first - No `.wikitool-tasks.json`, or a malformed one - a clear "no tracker configured" message -> Not fixed by retrying unchanged - configure or repair `.wikitool-tasks.json` first
- `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken -> Not transient - fix the path or unset the variable
- The provider was reachable at config-parse time but a read call failed mid-run; the full report (findings plus which checks ran) was printed first -> Start the unreachable provider (e.g. the tracker app), then retry plainly - The provider was reachable at config-parse time but a read call failed mid-run; the full report (findings plus which checks ran) was printed first -> Start the unreachable provider (e.g. the tracker app), then retry plainly
**NEVER** **NEVER**
@@ -1224,7 +1234,8 @@ The GTD weekly review.
- **no-open-loop**: a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of unpaged-project, so a rename on either side surfaces on both. - **no-open-loop**: a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of unpaged-project, so a rename on either side surfaces on both.
- **someday-stale**: a someday/maybe item untouched for longer than `thresholds.someday_stale_months`. - **someday-stale**: a someday/maybe item untouched for longer than `thresholds.someday_stale_months`.
- A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at`, a tracker project with no determinable creation date - is its own finding (`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip. - A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at`, a tracker project with no determinable creation date - is its own finding (`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip.
- Thresholds come from `.wikitool-tasks.json`, never from the schema. - Thresholds come from the tracker configuration, never from the schema.
- The tracker configuration is read from `.wikitool-tasks.json`, or from the file `WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be run against several trackers in turn). A set variable that names no file is an error, never "no tracker configured".
- Text output is one `[check] project: message` line per finding, preceded by a `Source:` line naming which access path answered and, for `superproductivity`'s `access: "snapshot"`, the snapshot's age. `--json` carries the same findings plus `checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` (`{"kind": ..., "detail": ...}` or `null`). - Text output is one `[check] project: message` line per finding, preceded by a `Source:` line naming which access path answered and, for `superproductivity`'s `access: "snapshot"`, the snapshot's age. `--json` carries the same findings plus `checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` (`{"kind": ..., "detail": ...}` or `null`).
- A provider that cannot be reached mid-run degrades only the checks that needed the failing call; the report is printed in full, then exit 1 follows - never rendered as if it were complete. - A provider that cannot be reached mid-run degrades only the checks that needed the failing call; the report is printed in full, then exit 1 follows - never rendered as if it were complete.
- Re-reads everything fresh on every call, so nothing is ever stale to re-fetch. - Re-reads everything fresh on every call, so nothing is ever stale to re-fetch.
@@ -3205,7 +3216,7 @@ Check that this instance is correctly configured.
- KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three. - KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three.
- Environment note: `ENVIRONMENT.md` is optional, so absent is `OK`; a still-templated one is a `WARN`. - Environment note: `ENVIRONMENT.md` is optional, so absent is `OK`; a still-templated one is a `WARN`.
- MCP `submit` tool: whether `.wikitool-upload.json` is present, absent or malformed, its limits, and how many submissions wait in `mcp-upload/`. Absent is `OK` and means the write path does not exist at all; malformed is a `FAIL`. - MCP `submit` tool: whether `.wikitool-upload.json` is present, absent or malformed, its limits, and how many submissions wait in `mcp-upload/`. Absent is `OK` and means the write path does not exist at all; malformed is a `FAIL`.
- Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` (no tracker configured), malformed is a `FAIL`. - Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` (no tracker configured), malformed is a `FAIL`. When `WIKITOOL_TASKS_CONFIG` is set, that file is read instead and the finding names it; a set variable that names no file is a `FAIL`, never `OK`.
- For a configured `superproductivity` provider, the configured `access` path's own state: `access: "api"` reports whether its local REST API answers `GET /health` with a ready renderer right now, `access: "snapshot"` whether a backup file is ready. The other access path is never attempted, and neither state is ever a `FAIL`. - For a configured `superproductivity` provider, the configured `access` path's own state: `access: "api"` reports whether its local REST API answers `GET /health` with a ready renderer right now, `access: "snapshot"` whether a backup file is ready. The other access path is never attempted, and neither state is ever a `FAIL`.
- For a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - never a `FAIL`; only a broken config block is. - For a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - never a `FAIL`; only a broken config block is.
- Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback. - Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback.
+2
View File
@@ -158,6 +158,8 @@ REQUIRED_IGNORE_CANARIES = (
# The task-tracker provider opt-in (Gitea #124) - same shape again: # The task-tracker provider opt-in (Gitea #124) - same shape again:
# per-checkout, never committed, once a credential lands in it. # per-checkout, never committed, once a credential lands in it.
".wikitool-tasks.json", ".wikitool-tasks.json",
# The live suite's tracker profiles (Gitea #156) - credentials again, one file per tracker.
".wikitool-tasks.d/probe.json",
) )
REQUIRED_TRACKED_PATHS = ( REQUIRED_TRACKED_PATHS = (
"reports/CONTRACT.md", "reports/CONTRACT.md",
+14 -9
View File
@@ -443,8 +443,8 @@ def check_tasks_provider() -> Check:
except ValidationError as exc: except ValidationError as exc:
return Check( return Check(
"tasks-provider", "FAIL", str(exc), "tasks-provider", "FAIL", str(exc),
f"Fix or delete {config.TASKS_CONFIG_FILENAME} - a broken one is not treated as " f"Fix or delete {config.tasks_config_label(config.ROOT)} - a broken one is not "
"'no tracker configured'", "treated as 'no tracker configured'",
) )
if cfg is None: if cfg is None:
return Check( return Check(
@@ -453,6 +453,9 @@ def check_tasks_provider() -> Check:
"review needs one, everything else does not)", "review needs one, everything else does not)",
) )
override = config.tasks_config_override()
via = f" [config: {override} via {config.ENV_TASKS_CONFIG}]" if override is not None else ""
if cfg.provider == "superproductivity": if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp from chemenu.tasks import superproductivity as sp
@@ -461,14 +464,14 @@ def check_tasks_provider() -> Check:
except ValidationError as exc: except ValidationError as exc:
return Check( return Check(
"tasks-provider", "FAIL", str(exc), "tasks-provider", "FAIL", str(exc),
f"Fix the 'superproductivity' section of {config.TASKS_CONFIG_FILENAME}", f"Fix the 'superproductivity' section of {config.tasks_config_label(config.ROOT)}",
) )
# Only the configured access path is a finding (Gitea #133) - the # Only the configured access path is a finding (Gitea #133) - the
# other one is not attempted at all, so it has nothing to report. # other one is not attempted at all, so it has nothing to report.
if sp_cfg.access == sp.ACCESS_API: if sp_cfg.access == sp.ACCESS_API:
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)" api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
return Check( return Check(
"tasks-provider", "OK", f"superproductivity: access=api; {api_state}", "tasks-provider", "OK", f"superproductivity: access=api; {api_state}{via}",
) )
try: try:
snapshot_path = sp.latest_snapshot_path(sp_cfg) snapshot_path = sp.latest_snapshot_path(sp_cfg)
@@ -476,7 +479,7 @@ def check_tasks_provider() -> Check:
except ValidationError as exc: except ValidationError as exc:
read_state = f"read path not ready ({exc})" read_state = f"read path not ready ({exc})"
return Check( return Check(
"tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}", "tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}{via}",
) )
if cfg.provider == "caldav": if cfg.provider == "caldav":
@@ -487,12 +490,12 @@ def check_tasks_provider() -> Check:
except ValidationError as exc: except ValidationError as exc:
return Check( return Check(
"tasks-provider", "FAIL", str(exc), "tasks-provider", "FAIL", str(exc),
f"Fix the 'caldav' section of {config.TASKS_CONFIG_FILENAME}", f"Fix the 'caldav' section of {config.tasks_config_label(config.ROOT)}",
) )
state = cd.probe(cd_cfg) state = cd.probe(cd_cfg)
return Check("tasks-provider", "OK", f"caldav: {state}") return Check("tasks-provider", "OK", f"caldav: {state}{via}")
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured") return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured{via}")
def check_session_id() -> Check: def check_session_id() -> Check:
@@ -642,7 +645,9 @@ def run_doctor() -> list[Check]:
"its limits, and how many submissions wait in `mcp-upload/`. Absent is `OK` and means " "its limits, and how many submissions wait in `mcp-upload/`. Absent is `OK` and means "
"the write path does not exist at all; malformed is a `FAIL`.", "the write path does not exist at all; malformed is a `FAIL`.",
"Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` " "Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` "
"(no tracker configured), malformed is a `FAIL`.", "(no tracker configured), malformed is a `FAIL`. When `WIKITOOL_TASKS_CONFIG` is set, "
"that file is read instead and the finding names it; a set variable that names no file "
"is a `FAIL`, never `OK`.",
"For a configured `superproductivity` provider, the configured `access` path's own " "For a configured `superproductivity` provider, the configured `access` path's own "
"state: `access: \"api\"` reports whether its local REST API answers `GET /health` " "state: `access: \"api\"` reports whether its local REST API answers `GET /health` "
"with a ready renderer right now, `access: \"snapshot\"` whether a backup file is ready. The other access " "with a ready renderer right now, `access: \"snapshot\"` whether a backup file is ready. The other access "
+9 -1
View File
@@ -104,7 +104,11 @@ def report_to_dict(report: ReviewReport) -> dict:
"A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at`, a " "A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at`, a "
"tracker project with no determinable creation date - is its own finding " "tracker project with no determinable creation date - is its own finding "
"(`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip.", "(`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip.",
"Thresholds come from `.wikitool-tasks.json`, never from the schema.", "Thresholds come from the tracker configuration, never from the schema.",
"The tracker configuration is read from `.wikitool-tasks.json`, or from the file "
"`WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be "
"run against several trackers in turn). A set variable that names no file is an "
"error, never \"no tracker configured\".",
"Text output is one `[check] project: message` line per finding, preceded by a " "Text output is one `[check] project: message` line per finding, preceded by a "
"`Source:` line naming which access path answered and, for `superproductivity`'s " "`Source:` line naming which access path answered and, for `superproductivity`'s "
"`access: \"snapshot\"`, the snapshot's age. `--json` carries the same findings plus " "`access: \"snapshot\"`, the snapshot's age. `--json` carries the same findings plus "
@@ -123,6 +127,10 @@ def report_to_dict(report: ReviewReport) -> dict:
reaction="Not fixed by retrying unchanged - configure or repair " reaction="Not fixed by retrying unchanged - configure or repair "
"`.wikitool-tasks.json` first", "`.wikitool-tasks.json` first",
), ),
cli_contract.Failure(
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
reaction="Not transient - fix the path or unset the variable",
),
cli_contract.Failure( cli_contract.Failure(
cause="The provider was reachable at config-parse time but a read call failed " cause="The provider was reachable at config-parse time but a read call failed "
"mid-run; the full report (findings plus which checks ran) was printed first", "mid-run; the full report (findings plus which checks ran) was printed first",
+30 -3
View File
@@ -58,6 +58,13 @@ def _parse_follow_up_at(text: str) -> datetime.date:
fail(f"--follow-up-at {text!r} must be YYYY-MM-DD.") fail(f"--follow-up-at {text!r} must be YYYY-MM-DD.")
def _read_config():
try:
return tasks_config.read_config(config.ROOT)
except ValidationError as exc:
fail(str(exc))
@app.command("new") @app.command("new")
@cli_contract.record(cli_contract.CommandRecord( @cli_contract.record(cli_contract.CommandRecord(
path="task new", path="task new",
@@ -87,6 +94,10 @@ def _parse_follow_up_at(text: str) -> datetime.date:
"`--follow-up-at` is refused without `--waiting` - it is never a due date on its own.", "`--follow-up-at` is refused without `--waiting` - it is never a due date on its own.",
"`--notes` carries a freetext backref (e.g. to the `kb/` source page the item came " "`--notes` carries a freetext backref (e.g. to the `kb/` source page the item came "
"from), stored verbatim, never parsed.", "from), stored verbatim, never parsed.",
"The tracker configuration is read from `.wikitool-tasks.json`, or from the file "
"`WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be "
"run against several trackers in turn). A set variable that names no file is an "
"error, never \"no tracker configured\".",
"No `.wikitool-tasks.json` fails immediately with a \"no tracker configured\" message.", "No `.wikitool-tasks.json` fails immediately with a \"no tracker configured\" message.",
"A provider whose configured access path has no write path (Super Productivity's " "A provider whose configured access path has no write path (Super Productivity's "
"`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` " "`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` "
@@ -102,6 +113,10 @@ def _parse_follow_up_at(text: str) -> datetime.date:
cause="No `.wikitool-tasks.json` - no tracker configured", cause="No `.wikitool-tasks.json` - no tracker configured",
reaction="Not transient - configure a tracker first", reaction="Not transient - configure a tracker first",
), ),
cli_contract.Failure(
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
reaction="Not transient - fix the path or unset the variable",
),
cli_contract.Failure( cli_contract.Failure(
cause="Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without " cause="Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without "
"`--waiting` or not `YYYY-MM-DD`", "`--waiting` or not `YYYY-MM-DD`",
@@ -191,7 +206,7 @@ def task_new_command(
) )
follow_up_date = _parse_follow_up_at(follow_up_at) if follow_up_at is not None else None follow_up_date = _parse_follow_up_at(follow_up_at) if follow_up_at is not None else None
cfg = tasks_config.read_config(config.ROOT) cfg = _read_config()
if cfg is None: if cfg is None:
fail( fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is " f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
@@ -252,6 +267,10 @@ def task_new_command(
cause="No `.wikitool-tasks.json` - no tracker configured", cause="No `.wikitool-tasks.json` - no tracker configured",
reaction="Not transient - configure a tracker first, then retry once", reaction="Not transient - configure a tracker first, then retry once",
), ),
cli_contract.Failure(
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
reaction="Not transient - fix the path or unset the variable",
),
), ),
examples=( examples=(
'tools/wikitool task list --project "Homelab migration"', 'tools/wikitool task list --project "Homelab migration"',
@@ -271,7 +290,7 @@ def task_list_command(
So a caller can get an item's id for `task close` without first running So a caller can get an item's id for `task close` without first running
`wikitool review` (Gitea #138). Read-only; works against either access `wikitool review` (Gitea #138). Read-only; works against either access
mode a provider offers.""" mode a provider offers."""
cfg = tasks_config.read_config(config.ROOT) cfg = _read_config()
if cfg is None: if cfg is None:
fail( fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is " f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
@@ -311,6 +330,10 @@ def task_list_command(
"write this stack makes.", "write this stack makes.",
"`--id` is the provider's own item id, from `task list` or a `review` finding - never a " "`--id` is the provider's own item id, from `task list` or a `review` finding - never a "
"title.", "title.",
"The tracker configuration is read from `.wikitool-tasks.json`, or from the file "
"`WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be "
"run against several trackers in turn). A set variable that names no file is an "
"error, never \"no tracker configured\".",
"No `.wikitool-tasks.json` fails with a \"no tracker configured\" message.", "No `.wikitool-tasks.json` fails with a \"no tracker configured\" message.",
"A provider whose configured access path has no write path (Super Productivity's " "A provider whose configured access path has no write path (Super Productivity's "
"`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` " "`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` "
@@ -322,6 +345,10 @@ def task_list_command(
cause="No `.wikitool-tasks.json` - no tracker configured", cause="No `.wikitool-tasks.json` - no tracker configured",
reaction="Not transient - configure a tracker first", reaction="Not transient - configure a tracker first",
), ),
cli_contract.Failure(
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
reaction="Not transient - fix the path or unset the variable",
),
cli_contract.Failure( cli_contract.Failure(
cause="An `--id` matching no tracker item right now", cause="An `--id` matching no tracker item right now",
reaction="Get a current id from `task list` or `review`, then retry once", reaction="Get a current id from `task list` or `review`, then retry once",
@@ -355,7 +382,7 @@ def task_close_command(
The only closing write this stack makes (Gitea #138); see The only closing write this stack makes (Gitea #138); see
`chemenu.tasks.protocol.TaskWriter.close_item` and `chemenu.tasks.protocol.TaskWriter.close_item` and
`docs/knowledge-and-commitment.md` for why.""" `docs/knowledge-and-commitment.md` for why."""
cfg = tasks_config.read_config(config.ROOT) cfg = _read_config()
if cfg is None: if cfg is None:
fail( fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is " f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
+29
View File
@@ -257,6 +257,35 @@ UPLOAD_CONFIG_FILENAME = ".wikitool-upload.json"
# else just fine, it only can't run the weekly review (#125, not yet built). # else just fine, it only can't run the weekly review (#125, not yet built).
TASKS_CONFIG_FILENAME = ".wikitool-tasks.json" TASKS_CONFIG_FILENAME = ".wikitool-tasks.json"
# Reads a tracker configuration from another file than `<root>/.wikitool-tasks.json`
# (Gitea #156), so one checkout can be run against several trackers one after the
# other without swapping a file. Registered in `_WIKITOOL_ENV`
# (tools/chemenu/tests/conftest.py). A relative value is taken from the working
# directory. Set but pointing at nothing is an error, never "no tracker".
ENV_TASKS_CONFIG = "WIKITOOL_TASKS_CONFIG"
def tasks_config_override() -> "Path | None":
"""The file `$WIKITOOL_TASKS_CONFIG` names, or `None` when it is unset or blank."""
value = os.environ.get(ENV_TASKS_CONFIG, "").strip()
return Path(value).expanduser().resolve() if value else None
def tasks_config_path(root: "Path | str") -> Path:
"""The tracker configuration file this process reads: the override when set,
else `<root>/.wikitool-tasks.json`."""
override = tasks_config_override()
return override if override is not None else Path(root) / TASKS_CONFIG_FILENAME
def tasks_config_label(root: "Path | str") -> str:
"""How a message names the file `tasks_config_path` reads - the bare file
name by default, the actual path and the variable when the override is on."""
override = tasks_config_override()
if override is None:
return TASKS_CONFIG_FILENAME
return f"{override} (from {ENV_TASKS_CONFIG})"
def default_author() -> str | None: def default_author() -> str | None:
"""The author to stamp a new source page with, per instance. """The author to stamp a new source page with, per instance.
+16 -9
View File
@@ -1,4 +1,5 @@
"""Reads `.wikitool-tasks.json` (Gitea #124) - `config.TASKS_CONFIG_FILENAME`. """Reads `.wikitool-tasks.json` (Gitea #124) - `config.TASKS_CONFIG_FILENAME`, or the
file `$WIKITOOL_TASKS_CONFIG` names instead (Gitea #156, `config.tasks_config_path`).
Same posture as `chemenu.upload.read_config`: absent means "no tracker Same posture as `chemenu.upload.read_config`: absent means "no tracker
configured for this instance", a legitimate state that `doctor` reports as OK, configured for this instance", a legitimate state that `doctor` reports as OK,
@@ -46,22 +47,28 @@ def read_config(root: "Any") -> "TasksConfig | None":
"""The tracker configuration for `root`, or `None` when the file is """The tracker configuration for `root`, or `None` when the file is
absent - which means no tracker is configured for this instance, not that absent - which means no tracker is configured for this instance, not that
one failed to load.""" one failed to load."""
from pathlib import Path
import json import json
path = Path(root) / config.TASKS_CONFIG_FILENAME path = config.tasks_config_path(root)
label = config.tasks_config_label(root)
if not path.is_file(): if not path.is_file():
if config.tasks_config_override() is not None:
raise ValidationError(
f"{config.ENV_TASKS_CONFIG} points at {path}, which is not a file. An override "
"that names nothing is never read as 'no tracker configured' - fix the path or "
"unset the variable."
)
return None return None
try: try:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc: except (OSError, json.JSONDecodeError) as exc:
raise ValidationError( raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME} is unreadable ({exc}). It decides which task " f"{label} is unreadable ({exc}). It decides which task "
"tracker the weekly review talks to, so a broken file is not treated as 'no " "tracker the weekly review talks to, so a broken file is not treated as 'no "
"tracker configured' - fix it or delete it deliberately." "tracker configured' - fix it or delete it deliberately."
) from exc ) from exc
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValidationError(f"{config.TASKS_CONFIG_FILENAME} must contain a JSON object.") raise ValidationError(f"{label} must contain a JSON object.")
expected = ( expected = (
'{"schema": 1, "provider": "superproductivity", ' '{"schema": 1, "provider": "superproductivity", '
@@ -78,13 +85,13 @@ def read_config(root: "Any") -> "TasksConfig | None":
someday_stale_months = int(raw_thresholds["someday_stale_months"]) someday_stale_months = int(raw_thresholds["someday_stale_months"])
except (KeyError, TypeError, ValueError) as exc: except (KeyError, TypeError, ValueError) as exc:
raise ValidationError( raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME} is missing or misshapes a required field ({exc}). " f"{label} is missing or misshapes a required field ({exc}). "
f"Expected: {expected}" f"Expected: {expected}"
) from exc ) from exc
if provider not in KNOWN_PROVIDERS: if provider not in KNOWN_PROVIDERS:
raise ValidationError( raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME}: unknown provider {provider!r}. " f"{label}: unknown provider {provider!r}. "
f"Known: {', '.join(KNOWN_PROVIDERS)}" f"Known: {', '.join(KNOWN_PROVIDERS)}"
) )
for field_name, value in ( for field_name, value in (
@@ -94,13 +101,13 @@ def read_config(root: "Any") -> "TasksConfig | None":
): ):
if value <= 0: if value <= 0:
raise ValidationError( raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME}: thresholds.{field_name} must be positive." f"{label}: thresholds.{field_name} must be positive."
) )
provider_config = data.get(provider) provider_config = data.get(provider)
if not isinstance(provider_config, dict): if not isinstance(provider_config, dict):
raise ValidationError( raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME}: missing or non-object {provider!r} section " f"{label}: missing or non-object {provider!r} section "
f"holding that provider's own connection settings. Expected: {expected}" f"holding that provider's own connection settings. Expected: {expected}"
) )
+1
View File
@@ -32,6 +32,7 @@ _WIKITOOL_ENV = (
"WIKITOOL_UPDATE_URL", "WIKITOOL_UPDATE_URL",
"WIKITOOL_UPDATE_TOKEN", "WIKITOOL_UPDATE_TOKEN",
"CHEMENU_ROOT", "CHEMENU_ROOT",
"WIKITOOL_TASKS_CONFIG",
) + tuple(var for var, _harness in HARNESS_ENV_VARS) ) + tuple(var for var, _harness in HARNESS_ENV_VARS)
# Environment git reads for identity or for where its repo lives. A stray # Environment git reads for identity or for where its repo lives. A stray
+7
View File
@@ -0,0 +1,7 @@
{
"sp_version": "19.1.0",
"recorded": "2026-09-30",
"how": "Packaged Super Productivity (.deb) headless in the chemenu-sp-live image, seeded from seed-backup.json; api/*.json are the raw answers of GET /health, /projects, /tasks and /tags after creating four items and closing one through wikitool's own writer.",
"seed-backup.json": "A backup the app wrote itself, plus three projects ('Chemenu Live-Test', 'Chemenu 8.0.0 freigeben', 'Windows nativ unterstützen' as demo-2) and the tag 'waiting', cloned from entities the app produced.",
"refresh": "See instructions/dev/tracker-testing.md, section Refreshing the fixtures."
}
+7
View File
@@ -0,0 +1,7 @@
{
"data": {
"rendererReady": true,
"server": "up"
},
"ok": true
}
+187
View File
@@ -0,0 +1,187 @@
{
"data": [
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"backlogTaskIds": [],
"doneOn": null,
"icon": "inbox",
"id": "INBOX_PROJECT",
"isArchived": false,
"isDone": false,
"isEnableBacklog": false,
"isHiddenFromMenu": false,
"noteIds": [],
"taskIds": [
"cbN7F3EMo67k9RrfMjS26",
"KfvaOD_bl7mIn-Fq0W9NE"
],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "500",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": false,
"primary": "rgb(144, 187, 165)",
"warn": "#e11826"
},
"title": "Inbox"
},
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"backlogTaskIds": [],
"created": 1790745002886,
"doneOn": null,
"icon": null,
"id": "chemenu-live-test",
"isArchived": false,
"isDone": false,
"isEnableBacklog": false,
"isHiddenFromMenu": false,
"noteIds": [],
"taskIds": [
"gwedT3TNeJm35AaKMj_Ov",
"dCf43Dr1Ei9Gc1DxysY5q",
"EBm18ZV1F1LrVV7hsauGu"
],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "500",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": false,
"primary": "rgb(144, 187, 165)",
"warn": "#e11826"
},
"title": "Chemenu Live-Test"
},
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"backlogTaskIds": [],
"created": 1790745002886,
"doneOn": null,
"icon": null,
"id": "demo-1",
"isArchived": false,
"isDone": false,
"isEnableBacklog": false,
"isHiddenFromMenu": false,
"noteIds": [],
"taskIds": [],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "500",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": false,
"primary": "rgb(144, 187, 165)",
"warn": "#e11826"
},
"title": "Chemenu 8.0.0 freigeben"
},
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"backlogTaskIds": [],
"created": 1790745002886,
"doneOn": null,
"icon": null,
"id": "demo-2",
"isArchived": false,
"isDone": false,
"isEnableBacklog": false,
"isHiddenFromMenu": false,
"noteIds": [],
"taskIds": [],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "500",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": false,
"primary": "rgb(144, 187, 165)",
"warn": "#e11826"
},
"title": "Windows nativ unterst\u00fctzen"
}
],
"ok": true
}
+88
View File
@@ -0,0 +1,88 @@
{
"data": [
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"color": null,
"created": 1790744929943,
"icon": "wb_sunny",
"id": "TODAY",
"taskIds": [
"cbN7F3EMo67k9RrfMjS26",
"gwedT3TNeJm35AaKMj_Ov",
"EBm18ZV1F1LrVV7hsauGu",
"KfvaOD_bl7mIn-Fq0W9NE"
],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "400",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": true,
"primary": "#6495ED",
"warn": "#e11826"
},
"title": "Today"
},
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"color": null,
"created": 1790745002886,
"icon": null,
"id": "tag-waiting",
"taskIds": [
"dCf43Dr1Ei9Gc1DxysY5q"
],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "400",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": true,
"primary": "#6495ED",
"warn": "#e11826"
},
"title": "waiting"
}
],
"ok": true
}
+64
View File
@@ -0,0 +1,64 @@
{
"data": [
{
"attachments": [],
"created": 1790744930824,
"dueDay": "2026-09-30",
"id": "KfvaOD_bl7mIn-Fq0W9NE",
"isDone": false,
"projectId": "INBOX_PROJECT",
"subTaskIds": [],
"tagIds": [],
"timeEstimate": 0,
"timeSpent": 0,
"timeSpentOnDay": {},
"title": "seed marker task"
},
{
"attachments": [],
"created": 1790764434509,
"dueDay": "2026-09-30",
"id": "EBm18ZV1F1LrVV7hsauGu",
"isDone": false,
"notes": "kb/gtd/technik/Chemenu Live-Test.md",
"projectId": "chemenu-live-test",
"subTaskIds": [],
"tagIds": [],
"timeEstimate": 0,
"timeSpent": 0,
"timeSpentOnDay": {},
"title": "Recorded: offene Aufgabe"
},
{
"attachments": [],
"created": 1790764434554,
"dueDay": "2026-08-01",
"id": "dCf43Dr1Ei9Gc1DxysY5q",
"isDone": false,
"projectId": "chemenu-live-test",
"subTaskIds": [],
"tagIds": [
"tag-waiting"
],
"timeEstimate": 0,
"timeSpent": 0,
"timeSpentOnDay": {},
"title": "Recorded: warte auf Antwort"
},
{
"attachments": [],
"created": 1790764434974,
"dueDay": "2026-09-30",
"id": "cbN7F3EMo67k9RrfMjS26",
"isDone": false,
"projectId": "INBOX_PROJECT",
"subTaskIds": [],
"tagIds": [],
"timeEstimate": 0,
"timeSpent": 0,
"timeSpentOnDay": {},
"title": "Recorded: Posteingang"
}
],
"ok": true
}
+1
View File
@@ -0,0 +1 @@
{"task": {"ids": ["KfvaOD_bl7mIn-Fq0W9NE"], "entities": {"KfvaOD_bl7mIn-Fq0W9NE": {"id": "KfvaOD_bl7mIn-Fq0W9NE", "subTaskIds": [], "timeSpentOnDay": {}, "timeSpent": 0, "timeEstimate": 0, "isDone": false, "title": "seed marker task", "tagIds": [], "created": 1790744930824, "attachments": [], "projectId": "INBOX_PROJECT", "dueDay": "2026-09-30"}}, "currentTaskId": null, "selectedTaskId": null, "taskDetailTargetPanel": null, "lastCurrentTaskId": null, "isDataLoaded": false, "dismissedCalendarAutoImportEventIdsByProvider": {}}, "project": {"ids": ["INBOX_PROJECT", "chemenu-live-test", "demo-1", "demo-2"], "entities": {"INBOX_PROJECT": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": ["KfvaOD_bl7mIn-Fq0W9NE"], "icon": "inbox", "id": "INBOX_PROJECT", "title": "Inbox"}, "chemenu-live-test": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "chemenu-live-test", "title": "Chemenu Live-Test", "created": 1790745002886}, "demo-1": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "demo-1", "title": "Chemenu 8.0.0 freigeben", "created": 1790745002886}, "demo-2": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "demo-2", "title": "Windows nativ unterst\u00fctzen", "created": 1790745002886}}}, "tag": {"ids": ["TODAY", "tag-waiting"], "entities": {"TODAY": {"color": null, "created": 1790744929943, "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": true, "primary": "#6495ED", "huePrimary": "400", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": ["KfvaOD_bl7mIn-Fq0W9NE"], "icon": "wb_sunny", "id": "TODAY", "title": "Today"}, "tag-waiting": {"color": null, "created": 1790745002886, "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": trLine truncated
+76
View File
@@ -0,0 +1,76 @@
"""Re-record `fixtures/sp/api/*.json` from a real Super Productivity (Gitea #156).
Not a test module. Run it where the packaged app is installed - the `chemenu-sp-live`
image, or any machine with `CHEMENU_LIVE_SP_BINARY` set - from `tools/`:
CHEMENU_LIVE_SP_BINARY=... python -m chemenu.tests.record_sp_fixtures <out-dir>
It starts the seeded headless app, creates four items and closes one through wikitool's own
writer, then stores the raw answers of `GET /health`, `/projects`, `/tasks` and `/tags`.
`instructions/dev/tracker-testing.md` says when to run it and what to update beside it.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import urllib.request
from datetime import date
from pathlib import Path
from chemenu.tasks import superproductivity as sp
from chemenu.tests import sp_headless
PROJECT = "Chemenu Live-Test"
def main(out: Path) -> None:
binary = os.environ.get("CHEMENU_LIVE_SP_BINARY")
if not binary:
sys.exit("CHEMENU_LIVE_SP_BINARY names no Super Productivity binary")
executable = Path(binary)
out.mkdir(parents=True, exist_ok=True)
work = Path(tempfile.mkdtemp(prefix="chemenu-record-sp-"))
process = sp_headless.start(executable, work / "profile", marker_project=PROJECT)
try:
cfg = sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_base_url": process.base_url, "api_token": process.token}
)
reader = sp.SuperProductivityApiReader(cfg)
writer = sp.SuperProductivityWriter(cfg, reader)
writer.create_item(
"Recorded: offene Aufgabe", project_name=PROJECT,
notes="kb/gtd/technik/Chemenu Live-Test.md",
)
writer.create_item(
"Recorded: warte auf Antwort", project_name=PROJECT,
waiting=True, follow_up_at=date(2026, 8, 1),
)
writer.create_item("Recorded: erledigt", project_name=PROJECT)
writer.create_item("Recorded: Posteingang", project_name=None)
done = next(i for i in reader.open_items(PROJECT).items if i.title == "Recorded: erledigt")
writer.close_item(done.id)
def get(path: str, auth: bool) -> object:
headers = {"Authorization": f"Bearer {process.token}"} if auth else {}
request = urllib.request.Request(process.base_url + path, headers=headers)
with urllib.request.urlopen(request, timeout=10) as response:
return json.loads(response.read())
for name, path, auth in (
("health", "/health", False), ("projects", "/projects", True),
("tasks", "/tasks", True), ("tags", "/tags", True),
):
(out / f"{name}.json").write_text(
json.dumps(get(path, auth), indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
print("recorded against Super Productivity", sp_headless.installed_version(executable))
finally:
process.stop()
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("usage: python -m chemenu.tests.record_sp_fixtures <out-dir>")
main(Path(sys.argv[1]))
+305
View File
@@ -0,0 +1,305 @@
"""A throwaway Super Productivity for the live tracker suite (Gitea #156).
Not a test module. `tracker_live` uses it to start the packaged desktop app with no
visible display, seeded from `fixtures/sp/seed-backup.json`, and to stop it again.
Everything here was found out by running the real app (v19.1.0, see
`instructions/dev/tracker-testing.md`):
- the Local REST API exists only when `SP_FORCE_LOCAL_REST_API=1` is set, listens on
the fixed port 3876 and takes the token from `SP_FORCE_LOCAL_REST_API_TOKEN`;
- a packaged app needs `NODE_ENV=DEV` plus `--custom-url` at its own bundled
`index.html`, or it opens a remote page;
- data comes in through a backup file in `<profile>/backups/`, and the app asks
"restore the newest backup?" with a `window.confirm` at startup, which has to be
accepted over the Chromium DevTools protocol - there is no flag for it.
Standard library only: the suite must run wherever `tools/requirements.txt` does.
"""
from __future__ import annotations
import base64
import json
import os
import secrets
import shutil
import signal
import socket
import struct
import subprocess
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
API_PORT = 3876
CDP_PORT = 9222
SEED_BACKUP = Path(__file__).parent / "fixtures" / "sp" / "seed-backup.json"
class LiveAbort(RuntimeError):
"""The live suite refuses to touch a tracker. Raised before the first write."""
def health(port: int = API_PORT, timeout: float = 2.0) -> Optional[dict]:
"""`GET /health` unwrapped, or `None` when nothing (Super Productivity) answers."""
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=timeout) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except (OSError, ValueError, urllib.error.URLError):
return None
if not isinstance(payload, dict) or not payload.get("ok"):
return None
data = payload.get("data")
return data if isinstance(data, dict) else None
# --- a minimal WebSocket client, just enough for the DevTools protocol -----------------
def _ws_connect(host: str, port: int, path: str, timeout: float) -> socket.socket:
sock = socket.create_connection((host, port), timeout=timeout)
key = base64.b64encode(os.urandom(16)).decode("ascii")
sock.sendall(
(
f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n"
).encode("ascii")
)
buffer = b""
while b"\r\n\r\n" not in buffer:
chunk = sock.recv(4096)
if not chunk:
raise OSError("DevTools closed the connection during the handshake")
buffer += chunk
if b" 101 " not in buffer.split(b"\r\n", 1)[0]:
raise OSError(f"DevTools refused the WebSocket upgrade: {buffer[:80]!r}")
return sock
def _ws_send(sock: socket.socket, text: str) -> None:
payload = text.encode("utf-8")
mask = os.urandom(4)
length = len(payload)
if length < 126:
header = struct.pack("!BB", 0x81, 0x80 | length)
elif length < 65536:
header = struct.pack("!BBH", 0x81, 0x80 | 126, length)
else:
header = struct.pack("!BBQ", 0x81, 0x80 | 127, length)
masked = bytes(byte ^ mask[i % 4] for i, byte in enumerate(payload))
sock.sendall(header + mask + masked)
def _recv_exact(sock: socket.socket, count: int) -> bytes:
data = b""
while len(data) < count:
chunk = sock.recv(count - len(data))
if not chunk:
raise OSError("DevTools closed the connection")
data += chunk
return data
def _ws_recv(sock: socket.socket) -> Optional[str]:
"""The next text message, or `None` on a close frame. Server frames are unmasked."""
while True:
first, second = _recv_exact(sock, 2)
opcode = first & 0x0F
length = second & 0x7F
if length == 126:
(length,) = struct.unpack("!H", _recv_exact(sock, 2))
elif length == 127:
(length,) = struct.unpack("!Q", _recv_exact(sock, 8))
payload = _recv_exact(sock, length)
if opcode == 0x8:
return None
if opcode in (0x1, 0x0):
return payload.decode("utf-8", "replace")
def _accept_dialogs(cdp_port: int, stop: threading.Event, log: list[str]) -> None:
"""Accept every JavaScript dialog the renderer opens until `stop` is set."""
while not stop.is_set():
try:
with urllib.request.urlopen(f"http://127.0.0.1:{cdp_port}/json", timeout=2) as resp:
targets = [t for t in json.loads(resp.read()) if t.get("type") == "page"]
if not targets:
raise OSError("no page target yet")
ws_url = targets[0]["webSocketDebuggerUrl"]
path = "/" + ws_url.split("/", 3)[3]
sock = _ws_connect("127.0.0.1", cdp_port, path, timeout=2)
except (OSError, ValueError, KeyError, IndexError):
stop.wait(0.5)
continue
try:
_ws_send(sock, json.dumps({"id": 1, "method": "Page.enable"}))
sock.settimeout(1.0)
while not stop.is_set():
try:
message = _ws_recv(sock)
except socket.timeout:
continue
if message is None:
break
event = json.loads(message)
if event.get("method") == "Page.javascriptDialogOpening":
params = event.get("params", {})
log.append(f"{params.get('type')}: {str(params.get('message'))[:120]}")
_ws_send(sock, json.dumps({
"id": 2, "method": "Page.handleJavaScriptDialog",
"params": {"accept": True},
}))
except (OSError, ValueError):
stop.wait(0.5)
finally:
sock.close()
# --- the app ---------------------------------------------------------------------------
@dataclass
class SpProcess:
process: "subprocess.Popen[bytes]"
profile_dir: Path
token: str
port: int
log_path: Path
dialogs: list[str]
_stop: threading.Event
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self.port}"
def stop(self) -> None:
self._stop.set()
if self.process.poll() is None:
try:
os.killpg(self.process.pid, signal.SIGTERM)
self.process.wait(timeout=15)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(self.process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
def installed_version(executable: Path) -> str:
"""The packaged version, for the report line - `unknown` rather than a guess."""
override = os.environ.get("CHEMENU_LIVE_SP_VERSION")
if override:
return override
if shutil.which("dpkg-query"):
result = subprocess.run(
["dpkg-query", "-W", "-f=${Version}", "superproductivity"],
capture_output=True, text=True, check=False,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return "unknown"
def _api_projects(base_url: str, token: str) -> list[str]:
request = urllib.request.Request(
f"{base_url}/projects", headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(request, timeout=5) as resp:
payload = json.loads(resp.read().decode("utf-8"))
return [str(p.get("title")) for p in payload.get("data", [])]
def start(
executable: Path,
profile_dir: Path,
*,
marker_project: str,
port: int = API_PORT,
cdp_port: int = CDP_PORT,
startup_timeout: float = 240.0,
) -> SpProcess:
"""Start the packaged app on a fresh, seeded profile and wait until it is ready.
Refuses when anything already answers on `port`: the port is fixed, so an answering
app is somebody's real Super Productivity, and seeding "our" one would either fail
or, worse, write into theirs.
"""
if health(port) is not None:
raise LiveAbort(
f"something already answers on 127.0.0.1:{port} - a running Super Productivity "
"owns the fixed API port, so the live suite will not start a second one next to "
"it. Quit that app, or point CHEMENU_LIVE_PROFILE at it deliberately."
)
if not executable.is_file():
raise LiveAbort(f"CHEMENU_LIVE_SP_BINARY does not name a file: {executable}")
if not os.environ.get("DISPLAY") and not shutil.which("xvfb-run"):
raise LiveAbort("no DISPLAY and no xvfb-run on PATH - the app needs a display to start.")
backups = profile_dir / "backups"
backups.mkdir(parents=True, exist_ok=True)
shutil.copyfile(SEED_BACKUP, backups / "2026-01-01_000000.json")
app_dir = executable.parent
token = secrets.token_urlsafe(24)
env = {
**os.environ,
"NODE_ENV": "DEV",
"SP_FORCE_LOCAL_REST_API": "1",
"SP_FORCE_LOCAL_REST_API_TOKEN": token,
}
command = [
str(executable),
f"--custom-url=file://{app_dir}/resources/app.asar/.tmp/angular-dist/browser/index.html",
f"--user-data-dir={profile_dir}",
"--disable-tray", "--no-sandbox",
f"--remote-debugging-port={cdp_port}",
]
if not os.environ.get("DISPLAY"):
command = ["xvfb-run", "-a", *command]
log_path = profile_dir.parent / f"{profile_dir.name}-sp.log"
with log_path.open("wb") as log:
process = subprocess.Popen(
command, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True
)
stop = threading.Event()
dialogs: list[str] = []
threading.Thread(
target=_accept_dialogs, args=(cdp_port, stop, dialogs), daemon=True
).start()
handle = SpProcess(process, profile_dir, token, port, log_path, dialogs, stop)
def log_tail() -> str:
try:
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return "(no log)"
return "\n".join(lines[-25:])
base_url = handle.base_url
deadline = time.monotonic() + startup_timeout
try:
while time.monotonic() < deadline:
if process.poll() is not None:
raise LiveAbort(
f"Super Productivity exited during startup; log {log_path}:\n{log_tail()}"
)
state = health(port)
if state and state.get("rendererReady") is True:
try:
if marker_project in _api_projects(base_url, token):
return handle
except (OSError, ValueError):
pass
time.sleep(2)
raise LiveAbort(
f"Super Productivity was not ready with project '{marker_project}' within "
f"{startup_timeout:.0f}s; log {log_path}:\n{log_tail()}"
)
except BaseException:
handle.stop()
raise
+25
View File
@@ -533,3 +533,28 @@ def test_doctor_json_is_machine_readable(instance, capsys):
rows = json.loads(capsys.readouterr().out) rows = json.loads(capsys.readouterr().out)
assert any(row["name"] == "structure" for row in rows) assert any(row["name"] == "structure" for row in rows)
assert all({"name", "status", "detail", "fix"} <= row.keys() 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, "provider": "superproductivity",
"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
"someday_stale_months": 5},
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:1",
"api_token": "t"},
}),
encoding="utf-8",
)
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")
+11
View File
@@ -591,3 +591,14 @@ def test_a_run_leaves_the_git_tree_untouched(tmp_path, monkeypatch):
["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True, check=True ["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True, check=True
).stdout ).stdout
assert before == after == "" assert before == after == ""
def test_cli_override_naming_a_missing_file_is_an_error_naming_the_path(tmp_path, monkeypatch):
from chemenu import config
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(tmp_path / "gone.json"))
result = runner.invoke(app, ["review"])
assert result.exit_code == 1
assert "gone.json" in result.output
assert "no task tracker is configured" not in result.output
+113
View File
@@ -0,0 +1,113 @@
"""Super Productivity's real answers, replayed without the app (Gitea #156, E4).
`fixtures/sp/` holds what the packaged app itself produced - a backup it wrote, and the raw
answers of its Local REST API after a fixed scenario (`MANIFEST.json` names the version and
the day). Every push checks the adapter against them, so a change to the adapter that the
hand-written fakes in `test_superproductivity.py` would let through fails here; whether the
*app* has changed since is what the nightly live suite answers (`test_tracker_live.py`).
"""
from __future__ import annotations
import contextlib
import http.server
import json
import shutil
import threading
from datetime import date
from pathlib import Path
import pytest
from chemenu.tasks import superproductivity as sp
FIXTURES = Path(__file__).parent / "fixtures" / "sp"
MARKER = "Chemenu Live-Test"
def _recorded(name: str) -> bytes:
return (FIXTURES / "api" / f"{name}.json").read_bytes()
@contextlib.contextmanager
def _replay_server():
routes = {
"/health": _recorded("health"), "/projects": _recorded("projects"),
"/tasks": _recorded("tasks"), "/tags": _recorded("tags"),
}
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
body = routes.get(self.path.split("?", 1)[0])
if body is None:
self.send_response(404)
self.end_headers()
return
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):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}"
finally:
server.shutdown()
thread.join(timeout=5)
def test_the_manifest_names_the_version_and_the_day():
manifest = json.loads((FIXTURES / "MANIFEST.json").read_text(encoding="utf-8"))
assert manifest["sp_version"].count(".") == 2
date.fromisoformat(manifest["recorded"])
def test_the_api_reader_reads_the_apps_own_answers():
with _replay_server() as base_url:
cfg = sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_base_url": base_url, "api_token": "irrelevant"}
)
reader = sp.SuperProductivityApiReader(cfg)
names = {p.name for p in reader.projects()}
assert names == {MARKER, "Chemenu 8.0.0 freigeben", "Windows nativ unterstützen"}
items = reader.open_items(MARKER)
assert {i.title for i in items.items} == {
"Recorded: offene Aufgabe", "Recorded: warte auf Antwort",
}
assert items.count == 2
assert [(w.title, w.follow_up_at) for w in items.waiting] == [
("Recorded: warte auf Antwort", date(2026, 8, 1)),
]
assert reader.open_items("Windows nativ unterstützen").count == 0
assert sp.health(cfg) is True
def test_the_inbox_item_is_invisible_to_every_project():
with _replay_server() as base_url:
cfg = sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_base_url": base_url, "api_token": "irrelevant"}
)
reader = sp.SuperProductivityApiReader(cfg)
seen = {i.title for p in reader.projects() for i in reader.open_items(p.name).items}
assert "Recorded: Posteingang" not in seen and "seed marker task" not in seen
def test_the_snapshot_reader_reads_a_backup_the_app_wrote(tmp_path):
backups = tmp_path / "backups"
backups.mkdir()
shutil.copyfile(FIXTURES / "seed-backup.json", backups / "2026-01-01_000000.json")
cfg = sp.SuperProductivityConfig.from_dict({"access": "snapshot", "backups_dir": str(backups)})
reader = sp.SuperProductivityReader(cfg)
assert {p.name for p in reader.projects()} == {
MARKER, "Chemenu 8.0.0 freigeben", "Windows nativ unterstützen",
}
assert reader.open_items(MARKER).count == 0
assert reader.someday_items() == []
+35
View File
@@ -387,3 +387,38 @@ def test_close_on_snapshot_access_is_refused_the_same_way_as_task_new(monkeypatc
result = _invoke(monkeypatch, kb_dir, ["task", "close", "--id", "t1"]) result = _invoke(monkeypatch, kb_dir, ["task", "close", "--id", "t1"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "access: 'api'" in result.output assert "access: 'api'" in result.output
# --- WIKITOOL_TASKS_CONFIG (Gitea #156) ----------------------------------------------
def test_an_override_naming_a_missing_file_fails_naming_the_path(monkeypatch, kb_dir):
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(kb_dir.parent / "no-such-profile.json"))
for args in (
["task", "list", "--project", "x"],
["task", "close", "--id", "t1"],
["task", "new", "--title", "x", "--inbox"],
):
result = _invoke(monkeypatch, kb_dir, args)
assert result.exit_code == 1, args
flat = "".join(result.output.split())
assert "no-such-profile.json" in flat, args
assert "notasktrackerisconfigured" not in flat, args
def test_an_override_points_a_command_at_another_tracker_without_touching_the_root_file(
monkeypatch, kb_dir, tmp_path,
):
root = kb_dir.parent
state = {"projects": [{**_project_record(), "taskIds": ["t1"]}],
"tasks": [{"id": "t1", "title": "Ueber das Profil", "isDone": False, "tagIds": []}]}
with _api_server(state) as server:
profile_dir = tmp_path / "profiles"
profile_dir.mkdir()
_write_tasks_config(profile_dir, _base_url(server))
profile = profile_dir / ".wikitool-tasks.json"
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
result = _invoke(monkeypatch, kb_dir, ["task", "list", "--project", "Ship Chemenu 7.0"])
assert result.exit_code == 0, result.output
assert "Ueber das Profil" in result.output
assert not (root / ".wikitool-tasks.json").exists()
+47
View File
@@ -79,3 +79,50 @@ def test_missing_provider_section_is_rejected(tmp_path):
_write(tmp_path, data) _write(tmp_path, data)
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
tasks_config.read_config(tmp_path) tasks_config.read_config(tmp_path)
# --- the WIKITOOL_TASKS_CONFIG override (Gitea #156) --------------------------------
def test_override_is_read_instead_of_the_root_file(tmp_path, monkeypatch):
root = tmp_path / "root"
root.mkdir()
_write(root, {**VALID, "provider": "asana"}) # would be rejected if it were read
profile = tmp_path / "profile.json"
profile.write_text(json.dumps(VALID), encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
assert tasks_config.read_config(root).provider == "superproductivity"
def test_override_works_with_no_root_file_at_all(tmp_path, monkeypatch):
profile = tmp_path / "profile.json"
profile.write_text(json.dumps(VALID), encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
assert tasks_config.read_config(tmp_path / "elsewhere") is not None
def test_a_relative_override_is_taken_from_the_working_directory(tmp_path, monkeypatch):
(tmp_path / "profile.json").write_text(json.dumps(VALID), encoding="utf-8")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", "profile.json")
assert tasks_config.read_config(tmp_path / "elsewhere") is not None
def test_an_override_naming_a_missing_file_is_an_error_never_no_tracker(tmp_path, monkeypatch):
_write(tmp_path, VALID) # a root file must not be a fallback for a broken override
missing = tmp_path / "gone.json"
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(missing))
with pytest.raises(ValidationError, match="gone.json"):
tasks_config.read_config(tmp_path)
def test_a_blank_override_is_no_override(tmp_path, monkeypatch):
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", " ")
assert tasks_config.read_config(tmp_path) is None
def test_a_broken_override_file_names_its_own_path(tmp_path, monkeypatch):
profile = tmp_path / "profile.json"
profile.write_text("{not json", encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
with pytest.raises(ValidationError, match="profile.json"):
tasks_config.read_config(tmp_path)
+279
View File
@@ -0,0 +1,279 @@
"""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.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_dict(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")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
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, "provider": "superproductivity", "thresholds": 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, "provider": "superproductivity", "thresholds": 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["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 ")
+206
View File
@@ -0,0 +1,206 @@
"""The live tracker suite's targets, guard and scenario (Gitea #156).
Not a test module - `test_tracker_live.py` runs it. The suite is the one place this
repo talks to a real tracker, so its safety rules live in code here rather than in a
comment:
1. Only the project named `MARKER_PROJECT` is ever written to. It must already exist;
this module never creates it in a tracker somebody else owns (`guard`).
2. Every item it creates carries `ITEM_PREFIX`, which names this run.
3. Nothing is ever deleted - the only closing write is `task close`.
4. Any violated precondition raises `LiveAbort` *before* the first write.
A target is one tracker the suite may run against. Three ways to name one, all through
the environment, none of them a fixed address:
- `CHEMENU_LIVE_SP_BINARY`: the packaged Super Productivity; the suite starts its own
seeded, headless copy (`sp_headless`).
- `CHEMENU_LIVE_CALDAV_URL` + `_USER` + `_PASSWORD`: a CalDAV server the suite may
provision the marker calendar in (Radicale in CI).
- `CHEMENU_LIVE_PROFILE`: a profile file `.wikitool-tasks.d/<name>.json` in the
checkout - a tracker of the user's own, used exactly as configured.
"""
from __future__ import annotations
import json
import os
import shutil
import tempfile
import uuid
from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path
from typing import Any, Callable, Optional
from chemenu import tasks
from chemenu.tasks import config as tasks_config
from chemenu.tasks.protocol import TaskReader, find_project, normalize_project_name
from chemenu.tests.sp_headless import LiveAbort
MARKER_PROJECT = "Chemenu Live-Test"
RUN_ID = uuid.uuid4().hex[:8]
ITEM_PREFIX = f"[live-test {RUN_ID}]"
PROFILE_DIR = ".wikitool-tasks.d"
KINDS = ("sp", "caldav", "profile")
THRESHOLDS = {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5}
REPO_ROOT = Path(__file__).resolve().parents[3]
@dataclass
class LiveTarget:
kind: str
label: str
version: str
config: dict[str, Any]
cleanup: Callable[[], None] = field(default=lambda: None)
def required_kinds(environ: "os._Environ[str] | dict[str, str]" = os.environ) -> set[str]:
"""Kinds that must run rather than skip (`CHEMENU_LIVE_REQUIRE=caldav,sp`) - CI's way of
making a skipped live suite a red one."""
raw = environ.get("CHEMENU_LIVE_REQUIRE", "")
return {part.strip() for part in raw.split(",") if part.strip()}
def _reject_home_relative(value: Any, where: str) -> None:
if isinstance(value, str) and value.startswith("~"):
raise LiveAbort(
f"{where}: '{value}' starts with '~'. Profiles carry absolute paths only - a "
"`~` would resolve against whichever HOME the run happens to have."
)
if isinstance(value, dict):
for key, inner in value.items():
_reject_home_relative(inner, f"{where}.{key}")
if isinstance(value, list):
for index, inner in enumerate(value):
_reject_home_relative(inner, f"{where}[{index}]")
def profile_path(name: str) -> Path:
if not name or "/" in name or name.startswith("."):
raise LiveAbort(f"CHEMENU_LIVE_PROFILE must be a bare profile name, got {name!r}.")
return REPO_ROOT / PROFILE_DIR / f"{name}.json"
def load_profile(name: str) -> LiveTarget:
path = profile_path(name)
if not path.is_file():
raise LiveAbort(f"no profile file {path} - see instructions/dev/tracker-testing.md.")
data = json.loads(path.read_text(encoding="utf-8"))
_reject_home_relative(data, path.name)
return LiveTarget(
kind="profile",
label=f"profile {name}",
version=str(data.get("live_test_version", "unknown")),
config={key: value for key, value in data.items() if key != "live_test_version"},
)
def caldav_target_from_env(environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
url = environ.get("CHEMENU_LIVE_CALDAV_URL")
if not url:
return None
user = environ.get("CHEMENU_LIVE_CALDAV_USER", "")
password = environ.get("CHEMENU_LIVE_CALDAV_PASSWORD", "")
if not user or not password:
raise LiveAbort("CHEMENU_LIVE_CALDAV_URL needs CHEMENU_LIVE_CALDAV_USER and _PASSWORD.")
return LiveTarget(
kind="caldav",
label="CalDAV " + url,
version=environ.get("CHEMENU_LIVE_CALDAV_VERSION", "unknown"),
config={
"schema": 1,
"provider": "caldav",
"thresholds": THRESHOLDS,
"caldav": {
"url": url, "username": user, "app_password": password,
"inbox_list": "Inbox", "someday_list": "Someday",
},
},
)
def sp_target_from_env(environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
binary = environ.get("CHEMENU_LIVE_SP_BINARY")
if not binary:
return None
from chemenu.tests import sp_headless
executable = Path(binary)
workdir = Path(tempfile.mkdtemp(prefix="chemenu-live-sp-"))
process = sp_headless.start(executable, workdir / "profile", marker_project=MARKER_PROJECT)
def cleanup() -> None:
process.stop()
shutil.rmtree(workdir, ignore_errors=True)
return LiveTarget(
kind="sp",
label="Super Productivity (headless)",
version=sp_headless.installed_version(executable),
config={
"schema": 1,
"provider": "superproductivity",
"thresholds": THRESHOLDS,
"superproductivity": {
"access": "api", "api_base_url": process.base_url, "api_token": process.token,
},
},
cleanup=cleanup,
)
def build_target(kind: str, environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
if kind == "sp":
return sp_target_from_env(environ)
if kind == "caldav":
return caldav_target_from_env(environ)
if kind == "profile":
name = environ.get("CHEMENU_LIVE_PROFILE")
return load_profile(name) if name else None
raise ValueError(f"unknown live tracker kind {kind!r}")
# --- guard and scenario ------------------------------------------------------------------
def reader_for(target_config: dict[str, Any]) -> TaskReader:
"""The reader `target_config` describes, built the way the commands build it."""
provider = target_config["provider"]
cfg = tasks_config.TasksConfig(
provider=provider,
thresholds=tasks_config.Thresholds(**target_config["thresholds"]),
provider_config=target_config[provider],
)
return tasks.build_reader(cfg)
def guard(reader: TaskReader, *, provision: Optional[Callable[[], None]] = None) -> None:
"""Refuse before the first write unless the marker project exists.
`provision`, given only for a target the suite owns end to end (Radicale in CI), creates
the marker first; a profile target never gets one, so a missing marker there is an abort.
"""
if find_project(reader, MARKER_PROJECT) is None and provision is not None:
provision()
if find_project(reader, MARKER_PROJECT) is None:
raise LiveAbort(
f"the tracker has no project named '{MARKER_PROJECT}'. The live suite writes "
"only into that project and never creates it in a tracker it does not own - "
"create it there first (instructions/dev/tracker-testing.md)."
)
def run_id_titles() -> tuple[str, str]:
return f"{ITEM_PREFIX} offen", f"{ITEM_PREFIX} warte auf Antwort"
def overdue_day(today: date) -> date:
return today - timedelta(days=30)
__all__ = [
"ITEM_PREFIX", "KINDS", "LiveAbort", "LiveTarget", "MARKER_PROJECT", "THRESHOLDS",
"build_target", "guard", "normalize_project_name", "overdue_day", "reader_for",
"required_kinds", "run_id_titles",
]
+2
View File
@@ -1,3 +1,5 @@
[pytest] [pytest]
pythonpath = . pythonpath = .
testpaths = chemenu/tests testpaths = chemenu/tests
markers =
live_tracker: talks to a real tracker (Super Productivity, CalDAV); skips without one - instructions/dev/tracker-testing.md