diff --git a/.gitea/scripts/start-radicale.sh b/.gitea/scripts/start-radicale.sh new file mode 100755 index 0000000..412571a --- /dev/null +++ b/.gitea/scripts/start-radicale.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Start a throwaway Radicale (a small CalDAV server) for the live tracker suite (Gitea #156). +# +# start-radicale.sh +# +# 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" < "$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" diff --git a/.gitea/sp-live/Dockerfile b/.gitea/sp-live/Dockerfile new file mode 100644 index 0000000..14d645e --- /dev/null +++ b/.gitea/sp-live/Dockerfile @@ -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}" diff --git a/.gitea/sp-live/resolve-version.sh b/.gitea/sp-live/resolve-version.sh new file mode 100755 index 0000000..912d912 --- /dev/null +++ b/.gitea/sp-live/resolve-version.sh @@ -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=` and `sha512=`, 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" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9e36e78..96f5f13 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -132,6 +132,21 @@ jobs: .venv/bin/python -m pytest -q \ --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 # `always()`: a red suite is exactly when the per-module numbers are # worth reading, and the upload must not disappear with the failure. diff --git a/.gitea/workflows/sp-live-image.yml b/.gitea/workflows/sp-live-image.yml new file mode 100644 index 0000000..c45d93e --- /dev/null +++ b/.gitea/workflows/sp-live-image.yml @@ -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: `:` 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<> "$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 }} diff --git a/.gitea/workflows/tracker-live.yml b/.gitea/workflows/tracker-live.yml new file mode 100644 index 0000000..1189b54 --- /dev/null +++ b/.gitea/workflows/tracker-live.yml @@ -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 diff --git a/.gitignore b/.gitignore index b9c636e..1934eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -141,6 +141,13 @@ npm-debug.log* # configured; `doctor` reports which. /.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=`, +# 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, # like reports/: recomputable from any commit, and `publish` runs `git add -A`, # so an unignored htmlcov/ would commit itself on the next content publish. diff --git a/CHANGES.md b/CHANGES.md index b1bc2ca..50c0092 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 @@ -79,6 +79,7 @@ concern - readable here, never shipped as something to parse. - fail() prints the command's ON FAILURE lines on stderr - 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) +- Live tracker suite: WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image **Low impact** - 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: +### 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/.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) Preparing the live tracker tests (#156) ran the real Super Productivity v19.1.0 headless for the diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7bd6a2c..fd9d664 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 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 Der `stack-dev`-Skill (`instructions/dev/`, nur in diesem Ursprungs-Repo vorhanden) fasst die diff --git a/INSTALL.md b/INSTALL.md index 9721d3e..74de06e 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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_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_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) | | `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 | diff --git a/VERSION b/VERSION index 99b389d..b7360cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.0.0-beta.2 +8.0.0-beta.3 diff --git a/instructions/dev/doc-pull-through.md b/instructions/dev/doc-pull-through.md index 5e58558..cd982f9 100644 --- a/instructions/dev/doc-pull-through.md +++ b/instructions/dev/doc-pull-through.md @@ -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 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 | + | 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//` or `instructions/dev//` | | 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 | diff --git a/instructions/dev/stack-dev/SKILL.md b/instructions/dev/stack-dev/SKILL.md index d6ffc3a..4b05e6d 100644 --- a/instructions/dev/stack-dev/SKILL.md +++ b/instructions/dev/stack-dev/SKILL.md @@ -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 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. + `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 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. diff --git a/instructions/dev/testing-conventions.md b/instructions/dev/testing-conventions.md index bc8aa19..e54acce 100644 --- a/instructions/dev/testing-conventions.md +++ b/instructions/dev/testing-conventions.md @@ -130,7 +130,7 @@ Whenever you add or change a test under `tools/chemenu/tests/`. fixture creates that machine. 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. 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 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 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 `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 diff --git a/instructions/dev/tracker-testing.md b/instructions/dev/tracker-testing.md new file mode 100644 index 0000000..3b1698d --- /dev/null +++ b/instructions/dev/tracker-testing.md @@ -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. + + +## 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) + + +## 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=` | A tracker of your own, described by `.wikitool-tasks.d/.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 ]`. 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/.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": ""}, + "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:////", "username": "", "app_password": "", + "inbox_list": "Inbox", "someday_list": "Someday"}, + "live_test_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= 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 +`:` 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. diff --git a/kb/gtd/INDEX.md b/kb/gtd/INDEX.md index 9685323..3f56a52 100644 --- a/kb/gtd/INDEX.md +++ b/kb/gtd/INDEX.md @@ -2,5 +2,12 @@ # 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 | diff --git a/kb/gtd/technik/Chemenu 8.0.0 freigeben.md b/kb/gtd/technik/Chemenu 8.0.0 freigeben.md new file mode 100644 index 0000000..0e023fc --- /dev/null +++ b/kb/gtd/technik/Chemenu 8.0.0 freigeben.md @@ -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. + + +## Beziehungen + +- **see-also:** [[Chemenu]] + diff --git a/kb/gtd/technik/Windows nativ unterstützen.md b/kb/gtd/technik/Windows nativ unterstützen.md new file mode 100644 index 0000000..7d8d990 --- /dev/null +++ b/kb/gtd/technik/Windows nativ unterstützen.md @@ -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. + + +## Beziehungen + +- **see-also:** [[Chemenu]] + diff --git a/kb/index.md b/kb/index.md index 6aeec0b..77d9354 100644 --- a/kb/index.md +++ b/kb/index.md @@ -13,13 +13,13 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be ## Statistics -- **Total Pages:** 182 +- **Total Pages:** 184 - **Comparisons:** 1 - **Concepts:** 80 - **Entities:** 72 -- **Gtd:** 0 +- **Gtd:** 2 - **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) | | `concepts/` | 80 | [concepts/INDEX.md](concepts/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) | ### 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) | | 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/ | Area | Pages | Index | diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 3f95908..7c062e8 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -254,6 +254,7 @@ Create one open item in the configured task tracker - never a kb/ page. - 0 success - 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 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) @@ -262,6 +263,7 @@ Create one open item in the configured task tracker - never a kb/ page. **ON FAILURE** - 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 - 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 @@ -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. - `--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. +- 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. - 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. @@ -316,10 +319,12 @@ List a project's open items - id, title, and whether each carries the WAITING st - 0 success - 0 A `--project` matching no tracker project - prints "No open items", not an error - 1 No `.wikitool-tasks.json` - no tracker configured +- 1 `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken **ON FAILURE** - 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** @@ -358,12 +363,14 @@ Mark one tracker item done - never delete it. - 0 success - 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 A read-only access path (Super Productivity's `access: "snapshot"`) **ON FAILURE** - 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 - 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. - `--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. - 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.** @@ -1204,11 +1212,13 @@ The GTD weekly review. - 0 success - 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 **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 +- `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 **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. - **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. -- 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`). - 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. @@ -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. - 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`. -- 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 `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. diff --git a/tools/chemenu/commands/docs_verify.py b/tools/chemenu/commands/docs_verify.py index a472c0b..3fadc0a 100644 --- a/tools/chemenu/commands/docs_verify.py +++ b/tools/chemenu/commands/docs_verify.py @@ -158,6 +158,8 @@ REQUIRED_IGNORE_CANARIES = ( # The task-tracker provider opt-in (Gitea #124) - same shape again: # per-checkout, never committed, once a credential lands in it. ".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 = ( "reports/CONTRACT.md", diff --git a/tools/chemenu/commands/doctor.py b/tools/chemenu/commands/doctor.py index e6c73b7..fc57753 100644 --- a/tools/chemenu/commands/doctor.py +++ b/tools/chemenu/commands/doctor.py @@ -443,8 +443,8 @@ def check_tasks_provider() -> Check: except ValidationError as exc: return Check( "tasks-provider", "FAIL", str(exc), - f"Fix or delete {config.TASKS_CONFIG_FILENAME} - a broken one is not treated as " - "'no tracker configured'", + f"Fix or delete {config.tasks_config_label(config.ROOT)} - a broken one is not " + "treated as 'no tracker configured'", ) if cfg is None: return Check( @@ -453,6 +453,9 @@ def check_tasks_provider() -> Check: "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": from chemenu.tasks import superproductivity as sp @@ -461,14 +464,14 @@ def check_tasks_provider() -> Check: except ValidationError as exc: return Check( "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 # other one is not attempted at all, so it has nothing to report. if sp_cfg.access == sp.ACCESS_API: api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)" return Check( - "tasks-provider", "OK", f"superproductivity: access=api; {api_state}", + "tasks-provider", "OK", f"superproductivity: access=api; {api_state}{via}", ) try: snapshot_path = sp.latest_snapshot_path(sp_cfg) @@ -476,7 +479,7 @@ def check_tasks_provider() -> Check: except ValidationError as exc: read_state = f"read path not ready ({exc})" 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": @@ -487,12 +490,12 @@ def check_tasks_provider() -> Check: except ValidationError as exc: return Check( "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) - 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: @@ -642,7 +645,9 @@ def run_doctor() -> list[Check]: "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`.", + "(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 " diff --git a/tools/chemenu/commands/review_cmd.py b/tools/chemenu/commands/review_cmd.py index 370a66f..41c641b 100644 --- a/tools/chemenu/commands/review_cmd.py +++ b/tools/chemenu/commands/review_cmd.py @@ -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 " "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 " @@ -123,6 +127,10 @@ def report_to_dict(report: ReviewReport) -> dict: reaction="Not fixed by retrying unchanged - configure or repair " "`.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( 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", diff --git a/tools/chemenu/commands/task_cmd.py b/tools/chemenu/commands/task_cmd.py index f772e45..193aab1 100644 --- a/tools/chemenu/commands/task_cmd.py +++ b/tools/chemenu/commands/task_cmd.py @@ -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.") +def _read_config(): + try: + return tasks_config.read_config(config.ROOT) + except ValidationError as exc: + fail(str(exc)) + + @app.command("new") @cli_contract.record(cli_contract.CommandRecord( 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.", "`--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.", "A provider whose configured access path has no write path (Super Productivity's " "`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", 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( cause="Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without " "`--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 - cfg = tasks_config.read_config(config.ROOT) + cfg = _read_config() if cfg is None: fail( 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", 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=( '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 `wikitool review` (Gitea #138). Read-only; works against either access mode a provider offers.""" - cfg = tasks_config.read_config(config.ROOT) + cfg = _read_config() if cfg is None: fail( 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.", "`--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.", "A provider whose configured access path has no write path (Super Productivity's " "`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", 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( cause="An `--id` matching no tracker item right now", 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 `chemenu.tasks.protocol.TaskWriter.close_item` and `docs/knowledge-and-commitment.md` for why.""" - cfg = tasks_config.read_config(config.ROOT) + cfg = _read_config() if cfg is None: fail( f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is " diff --git a/tools/chemenu/config.py b/tools/chemenu/config.py index 0a28585..c0a4f54 100644 --- a/tools/chemenu/config.py +++ b/tools/chemenu/config.py @@ -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). TASKS_CONFIG_FILENAME = ".wikitool-tasks.json" +# Reads a tracker configuration from another file than `/.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 `/.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: """The author to stamp a new source page with, per instance. diff --git a/tools/chemenu/tasks/config.py b/tools/chemenu/tasks/config.py index ea2e80e..46dba48 100644 --- a/tools/chemenu/tasks/config.py +++ b/tools/chemenu/tasks/config.py @@ -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 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 absent - which means no tracker is configured for this instance, not that one failed to load.""" - from pathlib import Path 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 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 try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: 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 configured' - fix it or delete it deliberately." ) from exc 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 = ( '{"schema": 1, "provider": "superproductivity", ' @@ -78,13 +85,13 @@ def read_config(root: "Any") -> "TasksConfig | None": someday_stale_months = int(raw_thresholds["someday_stale_months"]) except (KeyError, TypeError, ValueError) as exc: 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}" ) from exc if provider not in KNOWN_PROVIDERS: raise ValidationError( - f"{config.TASKS_CONFIG_FILENAME}: unknown provider {provider!r}. " + f"{label}: unknown provider {provider!r}. " f"Known: {', '.join(KNOWN_PROVIDERS)}" ) for field_name, value in ( @@ -94,13 +101,13 @@ def read_config(root: "Any") -> "TasksConfig | None": ): if value <= 0: 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) if not isinstance(provider_config, dict): 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}" ) diff --git a/tools/chemenu/tests/conftest.py b/tools/chemenu/tests/conftest.py index 3846273..6937a47 100644 --- a/tools/chemenu/tests/conftest.py +++ b/tools/chemenu/tests/conftest.py @@ -32,6 +32,7 @@ _WIKITOOL_ENV = ( "WIKITOOL_UPDATE_URL", "WIKITOOL_UPDATE_TOKEN", "CHEMENU_ROOT", + "WIKITOOL_TASKS_CONFIG", ) + tuple(var for var, _harness in HARNESS_ENV_VARS) # Environment git reads for identity or for where its repo lives. A stray diff --git a/tools/chemenu/tests/fixtures/sp/MANIFEST.json b/tools/chemenu/tests/fixtures/sp/MANIFEST.json new file mode 100644 index 0000000..01e966f --- /dev/null +++ b/tools/chemenu/tests/fixtures/sp/MANIFEST.json @@ -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." +} diff --git a/tools/chemenu/tests/fixtures/sp/api/health.json b/tools/chemenu/tests/fixtures/sp/api/health.json new file mode 100644 index 0000000..150a478 --- /dev/null +++ b/tools/chemenu/tests/fixtures/sp/api/health.json @@ -0,0 +1,7 @@ +{ + "data": { + "rendererReady": true, + "server": "up" + }, + "ok": true +} diff --git a/tools/chemenu/tests/fixtures/sp/api/projects.json b/tools/chemenu/tests/fixtures/sp/api/projects.json new file mode 100644 index 0000000..3d0ad7b --- /dev/null +++ b/tools/chemenu/tests/fixtures/sp/api/projects.json @@ -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 +} diff --git a/tools/chemenu/tests/fixtures/sp/api/tags.json b/tools/chemenu/tests/fixtures/sp/api/tags.json new file mode 100644 index 0000000..bd36724 --- /dev/null +++ b/tools/chemenu/tests/fixtures/sp/api/tags.json @@ -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 +} diff --git a/tools/chemenu/tests/fixtures/sp/api/tasks.json b/tools/chemenu/tests/fixtures/sp/api/tasks.json new file mode 100644 index 0000000..0fb6b84 --- /dev/null +++ b/tools/chemenu/tests/fixtures/sp/api/tasks.json @@ -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 +} diff --git a/tools/chemenu/tests/fixtures/sp/seed-backup.json b/tools/chemenu/tests/fixtures/sp/seed-backup.json new file mode 100644 index 0000000..2ca2b7e --- /dev/null +++ b/tools/chemenu/tests/fixtures/sp/seed-backup.json @@ -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": true, "primary": "#6495ED", "huePrimary": "400", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "tag-waiting", "title": "waiting"}}}, "globalConfig": {"appFeatures": {"isTimeTrackingEnabled": true, "isFocusModeEnabled": true, "isSchedulerEnabled": true, "isPlannerEnabled": true, "isBoardsEnabled": true, "isScheduleDayPanelEnabled": true, "isIssuesPanelEnabled": true, "isProjectNotesEnabled": true, "isSyncIconEnabled": true, "isSearchEnabled": true, "isDonatePageEnabled": true, "isHabitsEnabled": true, "isFinishDayEnabled": true}, "localization": {}, "tasks": {"isConfirmBeforeDelete": true, "isAutoAddWorkedOnToToday": true, "isAutoMarkParentAsDone": false, "isTrayShowCurrent": true, "defaultProjectId": "INBOX_PROJECT", "isMarkdownFormattingInNotesEnabled": true, "notesTemplate": "**How can I best achieve it now?**\n\n**What do I want?**\n\n**Why do I want it?**\n"}, "misc": {"isConfirmBeforeExit": false, "isConfirmBeforeExitWithoutFinishDay": true, "isMinimizeToTray": false, "isLocalRestApiEnabled": false, "isCheckForUpdates": true, "isTrayShowCurrentCountdown": true, "startOfNextDay": 0, "startOfNextDayTime": "00:00", "isDisableAnimations": false, "isVerticalActionBar": false, "isDisableCelebration": true, "isShowProductivityTipLonger": false, "customTheme": "default", "defaultStartPage": 0, "backgroundImageDark": null, "backgroundImageLight": null}, "shortSyntax": {"isEnableProject": true, "isEnableDue": true, "isEnableDeadline": false, "isEnableTag": true, "urlBehavior": "keep"}, "evaluation": {"isHideEvaluationSheet": false}, "idle": {"isOnlyOpenIdleWhenCurrentTask": false, "isSuppressIdleDuringFocusMode": false, "isEnableIdleTimeTracking": true, "minIdleTime": 300000}, "takeABreak": {"isTakeABreakEnabled": true, "isLockScreen": false, "isTimedFullScreenBlocker": false, "timedFullScreenBlockerDuration": 8000, "isFocusWindow": false, "takeABreakMessage": "You have been working for ${duration} without one. Go away from the computer! Take a short walk! Makes you more productive in the long run!", "takeABreakMinWorkingTime": 3600000, "takeABreakSnoozeTime": 900000, "motivationalImgs": []}, "dominaMode": {"isEnabled": false, "interval": 300000, "volume": 75, "text": "Your current task is: ${currentTaskTitle}", "voice": ""}, "focusMode": {"isSkipPreparation": false, "isShowPreparation": false, "isPlayTick": false, "focusModeSound": "off", "isPauseTrackingDuringBreak": true, "autoStartFocusOnPlay": false, "isManualBreakStart": false}, "flowtime": {"isBreakEnabled": false, "breakMode": "ratio", "breakPercentage": 20, "breakRules": []}, "clipboardImages": {"imagePath": null}, "pomodoro": {"duration": 1500000, "breakDuration": 300000, "longerBreakDuration": 900000, "cyclesBeforeLongerBreak": 4}, "keyboard": {"globalShowHide": "Ctrl+Shift+X", "globalToggleTaskStart": null, "globalAddNote": null, "globalAddTask": null, "globalToggleTaskWidget": null, "addNewTask": "Shift+A", "addNewProject": "Shift+P", "addNewNote": "Alt+N", "openProjectNotes": "Shift+N", "toggleTaskViewCustomizerPanel": "C", "toggleIssuePanel": "P", "focusSideNav": "Shift+D", "toggleSideNavMode": "Ctrl+B", "showHelp": "?", "showSearchBar": "Shift+F", "toggleBacklog": "B", "goToFocusMode": "F", "goToWorkView": "W", "goToScheduledView": "Shift+S", "goToTimeline": "Shift+T", "goToSettings": null, "zoomIn": "Ctrl++", "zoomOut": "Ctrl+-", "zoomDefault": "Ctrl+0", "triggerSync": "Ctrl+S", "taskEditTitle": null, "taskToggleDetailPanelOpen": "I", "taskOpenNotesPanel": "N", "taskOpenNotesFullscreen": null, "taskOpenEstimationDialog": "T", "taskSchedule": "S", "taskScheduleToday": "Shift+T", "taskScheduleTomorrow": null, "taskScheduleNextWeek": null, "taskScheduleNextMonth": null, "taskScheduleDeadline": "Shift+S", "taskUnschedule": "U", "taskToggleDone": "D", "taskAddSubTask": "A", "taskDuplicate": "Ctrl+D", "taskAddAttachment": "L", "taskDelete": "Backspace", "taskMoveToProject": "E", "taskOpenContextMenu": "Q", "selectPreviousTask": "K", "selectNextTask": "J", "moveTaskUp": "Ctrl+Shift+ArrowUp", "moveTaskDown": "Ctrl+Shift+ArrowDown", "moveTaskToTop": "Ctrl+Alt+ArrowUp", "moveTaskToBottom": "Ctrl+Alt+ArrowDown", "moveToBacklog": "Shift+B", "expandSubTasks": null, "collapseSubTasks": null, "togglePlay": "Y", "taskEditTags": "G", "taskToggleSelect": "X"}, "localBackup": {"isEnabled": true, "maxBackupFiles": 51}, "sound": {"volume": 75, "isIncreaseDoneSoundPitch": true, "doneSound": "ding-small-bell.mp3", "breakReminderSound": null, "trackTimeSound": null}, "timeTracking": {"defaultEstimate": 0, "defaultEstimateSubTasks": 0, "isNotifyWhenTimeEstimateExceeded": true, "isAutoStartNextTask": false, "isTrackingReminderEnabled": false, "isTrackingReminderShowOnMobile": false, "trackingReminderMinTime": 300000, "isTrackingReminderNotify": false, "isTrackingReminderFocusWindow": false}, "reminder": {"isCountdownBannerEnabled": true, "countdownDuration": 600000, "defaultTaskRemindOption": "AtStart", "isFocusWindow": false, "useAlarmStyleReminders": false, "notifyOnDueDate": true, "dueDateNotificationHour": 9}, "schedule": {"isWorkStartEndEnabled": true, "workStart": "9:00", "workEnd": "17:00", "isLunchBreakEnabled": false, "lunchBreakStart": "13:00", "lunchBreakEnd": "14:00"}, "sync": {"isEnabled": false, "isCompressionEnabled": false, "isEncryptionEnabled": false, "isUseSplitSyncFiles": false, "encryptKey": null, "syncProvider": null, "syncInterval": 60000, "isManualSyncOnly": false, "webDav": {"baseUrl": null, "userName": null, "password": null, "syncFolderPath": "super-productivity"}, "superSync": {"baseUrl": "https://sync.super-productivity.com", "userName": null, "password": null, "accessToken": null, "syncFolderPath": null}, "localFileSync": {"syncFolderPath": ""}, "nextcloud": {"serverUrl": null, "loginName": null, "userName": null, "password": null, "syncFolderPath": "super-productivity"}, "oneDrive": {"useCustomApp": true, "clientId": null, "tenantId": "common", "syncFolderPath": "Super Productivity"}}}, "note": {"ids": [], "entities": {}, "todayOrder": []}, "issueProvider": {"ids": [], "entities": {}}, "planner": {"days": {}}, "boards": {"boardCfgs": [{"id": "EISENHOWER_MATRIX", "title": "F.BOARDS.DEFAULT.EISENHAUER_MATRIX", "cols": 2, "panels": [{"id": "URGENT_AND_IMPORTANT", "title": "F.BOARDS.DEFAULT.URGENT_IMPORTANT", "includedTagIds": ["EM_IMPORTANT", "EM_URGENT"], "excludedTagIds": [], "taskIds": [], "taskDoneState": 1, "scheduledState": 1, "backlogState": 1, "isParentTasksOnly": true, "projectIds": [""]}, {"id": "NOT_URGENT_AND_IMPORTANT", "title": "F.BOARDS.DEFAULT.NOT_URGENT_IMPORTANT", "includedTagIds": ["EM_IMPORTANT"], "excludedTagIds": ["EM_URGENT"], "taskIds": [], "taskDoneState": 1, "scheduledState": 1, "backlogState": 1, "isParentTasksOnly": true, "projectIds": [""]}, {"id": "URGENT_AND_NOT_IMPORTANT", "title": "F.BOARDS.DEFAULT.URGENT_NOT_IMPORTANT", "includedTagIds": ["EM_URGENT"], "excludedTagIds": ["EM_IMPORTANT"], "taskIds": [], "taskDoneState": 1, "scheduledState": 1, "backlogState": 1, "isParentTasksOnly": true, "projectIds": [""]}, {"id": "NOT_URGENT_AND_NOT_IMPORTANT", "title": "F.BOARDS.DEFAULT.NOT_URGENT_NOT_IMPORTANT", "includedTagIds": [], "excludedTagIds": ["EM_IMPORTANT", "EM_URGENT"], "taskIds": [], "taskDoneState": 1, "scheduledState": 1, "backlogState": 1, "isParentTasksOnly": true, "projectIds": [""]}]}, {"id": "KANBAN_DEFAULT", "title": "F.BOARDS.DEFAULT.KANBAN", "cols": 3, "panels": [{"id": "TODO", "title": "F.BOARDS.DEFAULT.TO_DO", "taskDoneState": 3, "includedTagIds": [], "excludedTagIds": ["KANBAN_IN_PROGRESS"], "taskIds": [], "scheduledState": 1, "backlogState": 2, "isParentTasksOnly": false, "projectIds": [""]}, {"id": "IN_PROGRESS", "title": "F.BOARDS.DEFAULT.IN_PROGRESS", "taskDoneState": 3, "includedTagIds": ["KANBAN_IN_PROGRESS"], "excludedTagIds": [], "taskIds": [], "scheduledState": 1, "backlogState": 2, "isParentTasksOnly": false, "projectIds": [""]}, {"id": "DONE", "title": "F.BOARDS.DEFAULT.DONE", "taskDoneState": 2, "includedTagIds": [], "excludedTagIds": [], "taskIds": [], "scheduledState": 1, "backlogState": 2, "isParentTasksOnly": false, "projectIds": [""]}]}]}, "metric": {"ids": [], "entities": {}}, "simpleCounter": {"ids": ["STANDING_DESK_ID", "COFFEE_COUNTER", "STRETCHING_COUNTER"], "entities": {"STANDING_DESK_ID": {"id": "STANDING_DESK_ID", "title": "Standing Desk Timer", "isEnabled": false, "icon": "directions_walk", "type": "StopWatch", "countOnDay": {}, "isOn": false, "isTrackStreaks": true, "streakMinValue": 1800000, "streakMode": "specific-days", "streakWeekDays": {"0": false, "1": true, "2": true, "3": true, "4": true, "5": true, "6": false}}, "COFFEE_COUNTER": {"id": "COFFEE_COUNTER", "title": "Coffee Counter", "isEnabled": false, "icon": "free_breakfast", "type": "ClickCounter", "countOnDay": {}, "isOn": false, "isTrackStreaks": false, "streakMinValue": 2, "streakMode": "specific-days", "streakWeekDays": {"0": false, "1": true, "2": true, "3": true, "4": true, "5": true, "6": false}}, "STRETCHING_COUNTER": {"id": "STRETCHING_COUNTER", "title": "Stretching Counter", "isEnabled": false, "icon": "fitness_center", "type": "RepeatedCountdownReminder", "countOnDay": {}, "isOn": false, "isTrackStreaks": true, "streakMinValue": 8, "streakMode": "specific-days", "streakWeekDays": {"0": false, "1": true, "2": true, "3": true, "4": true, "5": true, "6": false}, "countdownDuration": 1800000}}}, "taskRepeatCfg": {"ids": [], "entities": {}}, "menuTree": {"projectTree": [], "tagTree": []}, "timeTracking": {"tag": {}, "project": {}}, "pluginUserData": [], "pluginMetadata": [], "reminders": [], "section": {"ids": [], "entities": {}}, "archiveYoung": {"task": {"ids": [], "entities": {}}, "timeTracking": {"tag": {}, "project": {}}, "lastTimeTrackingFlush": 0}, "archiveOld": {"task": {"ids": [], "entities": {}}, "timeTracking": {"tag": {}, "project": {}}, "lastTimeTrackingFlush": 0}} \ No newline at end of file diff --git a/tools/chemenu/tests/record_sp_fixtures.py b/tools/chemenu/tests/record_sp_fixtures.py new file mode 100644 index 0000000..84d34b1 --- /dev/null +++ b/tools/chemenu/tests/record_sp_fixtures.py @@ -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 + +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 ") + main(Path(sys.argv[1])) diff --git a/tools/chemenu/tests/sp_headless.py b/tools/chemenu/tests/sp_headless.py new file mode 100644 index 0000000..c412f0b --- /dev/null +++ b/tools/chemenu/tests/sp_headless.py @@ -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 `/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 diff --git a/tools/chemenu/tests/test_doctor.py b/tools/chemenu/tests/test_doctor.py index b0f0856..07faff5 100644 --- a/tools/chemenu/tests/test_doctor.py +++ b/tools/chemenu/tests/test_doctor.py @@ -533,3 +533,28 @@ def test_doctor_json_is_machine_readable(instance, capsys): rows = json.loads(capsys.readouterr().out) assert any(row["name"] == "structure" for row in rows) assert all({"name", "status", "detail", "fix"} <= row.keys() for row in rows) + + +def test_tasks_provider_fails_when_the_override_names_a_missing_file(instance, monkeypatch): + monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(config.ROOT / "gone.json")) + checks = doctor.run_doctor() + assert _status(checks, "tasks-provider") == "FAIL" + assert "gone.json" in _detail(checks, "tasks-provider") + + +def test_tasks_provider_names_an_active_override(instance, monkeypatch): + profile = config.ROOT / "profile.json" + profile.write_text( + json.dumps({ + "schema": 1, "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") diff --git a/tools/chemenu/tests/test_review.py b/tools/chemenu/tests/test_review.py index 9c2655e..eb31edb 100644 --- a/tools/chemenu/tests/test_review.py +++ b/tools/chemenu/tests/test_review.py @@ -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 ).stdout 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 diff --git a/tools/chemenu/tests/test_sp_recorded.py b/tools/chemenu/tests/test_sp_recorded.py new file mode 100644 index 0000000..c0ac1e9 --- /dev/null +++ b/tools/chemenu/tests/test_sp_recorded.py @@ -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() == [] diff --git a/tools/chemenu/tests/test_task_cmd.py b/tools/chemenu/tests/test_task_cmd.py index 5ea1421..cd57c8c 100644 --- a/tools/chemenu/tests/test_task_cmd.py +++ b/tools/chemenu/tests/test_task_cmd.py @@ -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"]) assert result.exit_code == 1 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() diff --git a/tools/chemenu/tests/test_tasks_config.py b/tools/chemenu/tests/test_tasks_config.py index fb22ec3..f4ad04c 100644 --- a/tools/chemenu/tests/test_tasks_config.py +++ b/tools/chemenu/tests/test_tasks_config.py @@ -79,3 +79,50 @@ def test_missing_provider_section_is_rejected(tmp_path): _write(tmp_path, data) with pytest.raises(ValidationError): 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) diff --git a/tools/chemenu/tests/test_tracker_live.py b/tools/chemenu/tests/test_tracker_live.py new file mode 100644 index 0000000..be92caa --- /dev/null +++ b/tools/chemenu/tests/test_tracker_live.py @@ -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 ") diff --git a/tools/chemenu/tests/tracker_live.py b/tools/chemenu/tests/tracker_live.py new file mode 100644 index 0000000..9c8e020 --- /dev/null +++ b/tools/chemenu/tests/tracker_live.py @@ -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/.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", +] diff --git a/tools/pytest.ini b/tools/pytest.ini index cc6f2ac..96c0e56 100644 --- a/tools/pytest.ini +++ b/tools/pytest.ini @@ -1,3 +1,5 @@ [pytest] pythonpath = . testpaths = chemenu/tests +markers = + live_tracker: talks to a real tracker (Super Productivity, CalDAV); skips without one - instructions/dev/tracker-testing.md