feat: live tracker suite - WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image (#156)
Files changed: - .gitea/scripts/start-radicale.sh - .gitea/sp-live/Dockerfile - .gitea/sp-live/resolve-version.sh - .gitea/workflows/ci.yml - .gitea/workflows/sp-live-image.yml - .gitea/workflows/tracker-live.yml - .gitignore - CHANGES.md - DEVELOPMENT.md - INSTALL.md - VERSION - instructions/dev/doc-pull-through.md - instructions/dev/stack-dev/SKILL.md - instructions/dev/testing-conventions.md - instructions/dev/tracker-testing.md - kb/gtd/INDEX.md - kb/gtd/technik/Chemenu 8.0.0 freigeben.md - kb/gtd/technik/Windows nativ unterstützen.md - kb/index.md - tools/CONTRACT.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/review_cmd.py - tools/chemenu/commands/task_cmd.py - tools/chemenu/config.py - tools/chemenu/tasks/config.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/fixtures/sp/MANIFEST.json - tools/chemenu/tests/fixtures/sp/api/health.json - tools/chemenu/tests/fixtures/sp/api/projects.json - tools/chemenu/tests/fixtures/sp/api/tags.json - tools/chemenu/tests/fixtures/sp/api/tasks.json - tools/chemenu/tests/fixtures/sp/seed-backup.json - tools/chemenu/tests/record_sp_fixtures.py - tools/chemenu/tests/sp_headless.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_review.py - tools/chemenu/tests/test_sp_recorded.py - tools/chemenu/tests/test_task_cmd.py - tools/chemenu/tests/test_tasks_config.py - tools/chemenu/tests/test_tracker_live.py - tools/chemenu/tests/tracker_live.py - tools/pytest.ini
This commit is contained in:
1 parent
529793b255
commit
b0c64772cc
43 files changed
+2279
-32
No files matched your search
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
# Start a throwaway Radicale (a small CalDAV server) for the live tracker suite (Gitea #156).
|
||||
#
|
||||
# start-radicale.sh <python-with-radicale> <work-dir>
|
||||
#
|
||||
# Radicale runs as a background process, not a service container: the jobs that call this
|
||||
# already run inside a job container, and a process on 127.0.0.1 needs no network wiring.
|
||||
# Appends the CHEMENU_LIVE_CALDAV_* variables to $GITHUB_ENV, so the pytest step that follows
|
||||
# runs the CalDAV half of the live suite - with CHEMENU_LIVE_REQUIRE=caldav set by the
|
||||
# workflow, so a server that did not come up fails the run instead of skipping it.
|
||||
set -eu
|
||||
python="$1"
|
||||
work="$2"
|
||||
mkdir -p "$work"
|
||||
printf 'ci:ci-live-secret\n' > "$work/users"
|
||||
cat > "$work/config" <<CONF
|
||||
[server]
|
||||
hosts = 127.0.0.1:5232
|
||||
[auth]
|
||||
type = htpasswd
|
||||
htpasswd_filename = $work/users
|
||||
htpasswd_encryption = plain
|
||||
[storage]
|
||||
filesystem_folder = $work/data
|
||||
CONF
|
||||
"$python" -m radicale --config "$work/config" > "$work/radicale.log" 2>&1 &
|
||||
i=0
|
||||
until curl -fsS -o /dev/null -u ci:ci-live-secret -X PROPFIND -H 'Depth: 0' http://127.0.0.1:5232/ci/; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -gt 30 ]; then
|
||||
echo "radicale did not come up:" >&2
|
||||
cat "$work/radicale.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
version="$("$python" -m radicale --version 2>/dev/null | tail -n 1)"
|
||||
{
|
||||
echo "CHEMENU_LIVE_CALDAV_URL=http://127.0.0.1:5232/ci/"
|
||||
echo "CHEMENU_LIVE_CALDAV_USER=ci"
|
||||
echo "CHEMENU_LIVE_CALDAV_PASSWORD=ci-live-secret"
|
||||
echo "CHEMENU_LIVE_CALDAV_VERSION=radicale $version"
|
||||
} >> "${GITHUB_ENV:?start-radicale.sh runs inside a workflow step}"
|
||||
echo "radicale $version is up"
|
||||
@@ -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}"
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Which Super Productivity release, and the sha512 of its .deb, straight from the
|
||||
# update channel the desktop clients themselves follow (`latest-linux.yml`).
|
||||
#
|
||||
# resolve-version.sh the newest release
|
||||
# resolve-version.sh 19.1.0 that release
|
||||
#
|
||||
# Prints two lines, `version=<x>` and `sha512=<base64>`, so a workflow can append the
|
||||
# output to $GITHUB_OUTPUT as it is. Gitea #156: the test image follows the channel
|
||||
# rather than a pin, because installed apps update on their own and a pinned old
|
||||
# version would be tested against while users run the new one.
|
||||
set -eu
|
||||
base=https://github.com/super-productivity/super-productivity/releases
|
||||
if [ "${1:-latest}" = latest ]; then
|
||||
url="$base/latest/download/latest-linux.yml"
|
||||
else
|
||||
url="$base/download/v$1/latest-linux.yml"
|
||||
fi
|
||||
yml="$(curl -fsSL "$url")"
|
||||
version="$(printf '%s\n' "$yml" | sed -n 's/^version: *//p' | head -n 1)"
|
||||
sha512="$(printf '%s\n' "$yml" | awk '/url: superProductivity-amd64\.deb/ {found=1; next} found && /sha512:/ {print $2; exit}')"
|
||||
if [ -z "$version" ] || [ -z "$sha512" ]; then
|
||||
echo "resolve-version: no version/sha512 for the amd64 .deb in $url" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'version=%s\nsha512=%s\n' "$version" "$sha512"
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Builds the image the nightly `tracker-live` run executes in: Debian, the packaged Super
|
||||
# Productivity, a virtual display and the tools the suite needs (Gitea #156). It lives in
|
||||
# this Gitea instance's registry as `gitea.nehmer.net/torben/chemenu-sp-live`.
|
||||
#
|
||||
# The image follows the update channel, not a pin. Installed desktop apps update themselves,
|
||||
# so a pinned old version would be tested while users already run the new one. Every day this
|
||||
# workflow asks `latest-linux.yml` (`.gitea/sp-live/resolve-version.sh`) which release is
|
||||
# current, and builds only when the registry does not hold that tag yet. It also rebuilds once
|
||||
# a month regardless, so the Debian layers behind the app do not age unnoticed.
|
||||
#
|
||||
# Tags: `:<sp-version>` always, `:latest` only when that version is what the channel says.
|
||||
# A manual run with `sp_version` builds an older release (to reproduce a red night against
|
||||
# the version it went red on) and therefore never moves `:latest`.
|
||||
#
|
||||
# Runner shape follows torben/gitea-mcp, `.gitea/workflows/binford-release.yaml`: the
|
||||
# `container-builder` label, a remote BuildKit on the runner host, and the registry login from
|
||||
# 1Password. `OP_SERVICE_ACCOUNT_TOKEN` is a user-level secret that covers `torben/*`.
|
||||
#
|
||||
# After the very first push the package has to be linked to this repository once, by hand, in
|
||||
# the Gitea UI - a step no workflow can do. Until then the image builds and pulls fine; only
|
||||
# the package page shows no repository.
|
||||
|
||||
name: SP live image
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 04:10 UTC, an hour after `nightly` and well before `tracker-live` (05:00), so a new
|
||||
# release is in the registry by the time the suite looks for it.
|
||||
- cron: '10 4 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sp_version:
|
||||
description: 'Super Productivity release to build (default: the current one)'
|
||||
required: false
|
||||
force:
|
||||
description: 'Rebuild even if the tag already exists (true/false)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.nehmer.net/torben
|
||||
IMAGE_NAME: chemenu-sp-live
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: container-builder
|
||||
container:
|
||||
image: debian:trixie-slim
|
||||
steps:
|
||||
- name: Install CI dependencies
|
||||
# `nodejs` is for act_runner's JavaScript actions, not for us - see ci.yml.
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends \
|
||||
git nodejs curl docker-cli docker-buildx ca-certificates iproute2 gawk
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve the Super Productivity release
|
||||
id: sp
|
||||
env:
|
||||
REQUESTED: ${{ inputs.sp_version }}
|
||||
run: |
|
||||
set -eu
|
||||
channel="$(.gitea/sp-live/resolve-version.sh latest)"
|
||||
channel_version="$(printf '%s\n' "$channel" | sed -n 's/^version=//p')"
|
||||
if [ -n "${REQUESTED:-}" ]; then
|
||||
wanted="$(.gitea/sp-live/resolve-version.sh "$REQUESTED")"
|
||||
else
|
||||
wanted="$channel"
|
||||
fi
|
||||
{
|
||||
printf '%s\n' "$wanted"
|
||||
echo "channel_version=$channel_version"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "$wanted"
|
||||
|
||||
- name: Load secrets from 1Password
|
||||
uses: 1password/load-secrets-action@v2
|
||||
with:
|
||||
export-env: true
|
||||
env:
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
REGISTRY_USER: op://CI-CD/gitea-package-token/username
|
||||
REGISTRY_PAT: op://CI-CD/gitea-package-token/password
|
||||
|
||||
- name: BuildKit setup (remote builder)
|
||||
run: |
|
||||
HOST_IP=$(ip route | awk '/default/ { print $3 }')
|
||||
docker buildx create --name remote-builder --driver remote tcp://$HOST_IP:1234 --use --bootstrap
|
||||
|
||||
- name: Log in to the container registry
|
||||
run: |
|
||||
echo "$REGISTRY_PAT" | docker login gitea.nehmer.net -u "$REGISTRY_USER" --password-stdin
|
||||
|
||||
- name: Decide whether to build
|
||||
id: decide
|
||||
env:
|
||||
SP_VERSION: ${{ steps.sp.outputs.version }}
|
||||
CHANNEL_VERSION: ${{ steps.sp.outputs.channel_version }}
|
||||
FORCE: ${{ inputs.force }}
|
||||
run: |
|
||||
set -eu
|
||||
ref="$REGISTRY/$IMAGE_NAME:$SP_VERSION"
|
||||
build=false
|
||||
why=""
|
||||
if [ "${FORCE:-false}" = true ]; then
|
||||
build=true; why="forced"
|
||||
elif [ "$(date -u +%d)" = 01 ]; then
|
||||
build=true; why="monthly rebuild"
|
||||
elif ! docker buildx imagetools inspect "$ref" > /dev/null 2>&1; then
|
||||
build=true; why="$ref is not in the registry yet"
|
||||
fi
|
||||
tags="$ref"
|
||||
if [ "$SP_VERSION" = "$CHANNEL_VERSION" ]; then
|
||||
tags="$tags
|
||||
$REGISTRY/$IMAGE_NAME:latest"
|
||||
fi
|
||||
{
|
||||
echo "build=$build"
|
||||
echo "tags<<EOF"
|
||||
echo "$tags"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "build=$build ${why:+($why)}; tags: $tags"
|
||||
|
||||
- name: Build and push
|
||||
if: steps.decide.outputs.build == 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .gitea/sp-live
|
||||
file: .gitea/sp-live/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.decide.outputs.tags }}
|
||||
build-args: |
|
||||
SP_VERSION=${{ steps.sp.outputs.version }}
|
||||
SP_SHA512=${{ steps.sp.outputs.sha512 }}
|
||||
@@ -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
|
||||
@@ -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=<name>`,
|
||||
# instructions/dev/tracker-testing.md). They carry the same credentials as the file above
|
||||
# and are per-checkout for the same reason. A directory pattern, anchored: nothing in it is
|
||||
# ever tracked, so no negation has to rescue anything.
|
||||
/.wikitool-tasks.d/
|
||||
|
||||
# Coverage output from `pytest --cov` (see .gitea/workflows/ci.yml). Derived,
|
||||
# like reports/: recomputable from any commit, and `publish` runs `git add -A`,
|
||||
# so an unignored htmlcov/ would commit itself on the next content publish.
|
||||
|
||||
+39
-1
@@ -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:
|
||||
<!-- /wikitool:bumps -->
|
||||
|
||||
### Live tracker suite: WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image (Gitea #156)
|
||||
|
||||
The task-tracker adapters were only ever tested against fakes, which is how #162 stayed hidden.
|
||||
This adds a suite that runs the documented `task new` / `task list` / `task close` / `review`
|
||||
workflow against a real tracker, and the machinery to run it on a clock.
|
||||
|
||||
- **`WIKITOOL_TASKS_CONFIG`** names the tracker configuration `task`, `review` and `doctor`
|
||||
read instead of `.wikitool-tasks.json`, so one checkout can be run against several trackers in
|
||||
turn. A set variable that names no file is an error naming the path - never "no tracker
|
||||
configured". Listed in INSTALL.md's variable table and in the affected command records.
|
||||
- **The `live_tracker` suite** (`tests/test_tracker_live.py`, `tests/tracker_live.py`) writes only
|
||||
into a project named `Chemenu Live-Test`, prefixes every item with the run id, deletes
|
||||
nothing, and aborts before the first write when the marker project is missing. It skips
|
||||
without a tracker; `CHEMENU_LIVE_REQUIRE` turns a missing one into a failure. A tracker of
|
||||
your own is named by `.wikitool-tasks.d/<name>.json` (gitignored, absolute paths only) and
|
||||
`CHEMENU_LIVE_PROFILE`.
|
||||
- **Headless Super Productivity** (`tests/sp_headless.py`) starts the packaged app on a seeded
|
||||
profile, accepts its startup restore dialog over the DevTools protocol and refuses to start
|
||||
when something already answers on the fixed API port.
|
||||
- **Recorded fixtures** (`tests/fixtures/sp/`) hold real answers of v19.1.0 and a backup the app
|
||||
wrote itself; `test_sp_recorded.py` replays them in the default run, and
|
||||
`tests/record_sp_fixtures.py` records them again.
|
||||
- **CI.** `ci.yml` runs the CalDAV half against a Radicale process on every push.
|
||||
`tracker-live.yml` runs both providers nightly inside the `chemenu-sp-live` image, which
|
||||
`sp-live-image.yml` builds daily when the update channel has a new Super Productivity version
|
||||
and monthly regardless. The image follows the channel rather than a pin, because installed
|
||||
desktop clients update themselves.
|
||||
- **Docs.** `instructions/dev/tracker-testing.md` has the profile procedure per tracker, when an
|
||||
agent dispatches the nightly run, what a red night means and how to refresh the fixtures;
|
||||
`testing-conventions.md` names the live suite as the one deliberate exception to the hermetic
|
||||
default. `docs verify` gained an ignore canary for `.wikitool-tasks.d/`.
|
||||
- **Demo corpus.** `kb/gtd/technik/` gains two project pages taken from the Windows and release
|
||||
work, matching projects in the seed backup, so `review` has something to join.
|
||||
|
||||
The Docker package `chemenu-sp-live` has to be linked to this repository once, by hand, after the
|
||||
first image build.
|
||||
|
||||
### Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (Gitea #162)
|
||||
|
||||
Preparing the live tracker tests (#156) ran the real Super Productivity v19.1.0 headless for the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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/<name>/` or `instructions/dev/<name>/` |
|
||||
| A per-checkout configuration file an instance owns (`.wikitool-tasks.json`, `.wikitool-telemetry.json`, `.wikitool-remotes.json`, `.wikitool-upload.json`) | [INSTALL.md](../../INSTALL.md) § Konfiguration, where an operator looks the shape up; the [setup-instance.md](../setup-instance.md) decision point that offers it during setup; and `doctor`'s own row in [tools/CONTRACT.md](../../tools/CONTRACT.md), since `doctor` is what reports the file's state |
|
||||
| A 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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
type: types/instruction.md
|
||||
name: tracker-testing
|
||||
description: How the task-tracker adapters (Super Productivity, CalDAV) are tested against a real tracker - the live suite, the profile procedure for a tracker of your own, the nightly workflow and when an agent triggers it, what a red night means, and how to refresh the recorded fixtures.
|
||||
---
|
||||
# Test a tracker adapter against a real tracker
|
||||
|
||||
The default `pytest` run never talks to a tracker: it reads recorded answers
|
||||
(`tools/chemenu/tests/fixtures/sp/`) and fakes. That is enough to keep the parsing honest and
|
||||
not enough to know that the adapter still works against the tracker as it ships today - a
|
||||
tracker is software somebody else releases (Gitea #156, and the adapter defects of #162 that
|
||||
only a real Super Productivity could show). So a second suite exists, marked `live_tracker`,
|
||||
that runs the documented `task new` / `task list` / `task close` / `review` workflow end to end.
|
||||
It skips when no tracker is configured and fails when CI says one must be there.
|
||||
|
||||
<!-- wikitool:toc -->
|
||||
## Contents
|
||||
|
||||
- [The three ways to name a tracker](#the-three-ways-to-name-a-tracker)
|
||||
- [What the suite writes, and what it never does](#what-the-suite-writes-and-what-it-never-does)
|
||||
- [Run it against a tracker of your own](#run-it-against-a-tracker-of-your-own)
|
||||
- [The two workflows and the image](#the-two-workflows-and-the-image)
|
||||
- [When an agent triggers the nightly run](#when-an-agent-triggers-the-nightly-run)
|
||||
- [A red night](#a-red-night)
|
||||
- [Refreshing the fixtures](#refreshing-the-fixtures)
|
||||
- [Steps](#steps)
|
||||
- [Scope](#scope)
|
||||
<!-- /wikitool:toc -->
|
||||
|
||||
## The three ways to name a tracker
|
||||
|
||||
All through the environment; none of them is a fixed address, and none of them is read by a
|
||||
plain `pytest` run unless you set it.
|
||||
|
||||
| Variable | Tracker | Who owns it |
|
||||
|---|---|---|
|
||||
| `CHEMENU_LIVE_SP_BINARY` | The packaged Super Productivity (`.deb` layout). The suite starts its own headless copy on a fresh profile, seeded from `fixtures/sp/seed-backup.json`, and stops it again. | The suite |
|
||||
| `CHEMENU_LIVE_CALDAV_URL`, `_USER`, `_PASSWORD` (optional `_VERSION` for the report line) | A CalDAV collection. In CI it is a throwaway Radicale started by `.gitea/scripts/start-radicale.sh`. | The suite - it may create the marker calendar |
|
||||
| `CHEMENU_LIVE_PROFILE=<name>` | A tracker of your own, described by `.wikitool-tasks.d/<name>.json` in the checkout, used exactly as configured. | You - the suite never creates anything but items |
|
||||
|
||||
`CHEMENU_LIVE_REQUIRE=sp,caldav,profile` (any subset) turns "no such tracker configured" into
|
||||
a failure instead of a skip. CI sets it; without it a workflow that lost its server would go
|
||||
green by skipping, which is the outcome the suite exists to rule out.
|
||||
|
||||
The commands under test read their configuration from `.wikitool-tasks.json`, or from the file
|
||||
`WIKITOOL_TASKS_CONFIG` names when that variable is set. The live suite sets the variable per
|
||||
test, so it never touches the checkout's own `.wikitool-tasks.json`; you can use the same
|
||||
variable to run `task` and `review` by hand against a second tracker.
|
||||
|
||||
## What the suite writes, and what it never does
|
||||
|
||||
The safety rules are code (`tools/chemenu/tests/tracker_live.py`), and
|
||||
`test_tracker_live.py` proves them against fakes on every run - they are not a promise in this
|
||||
file.
|
||||
|
||||
- It writes only into the project named **`Chemenu Live-Test`** (`MARKER_PROJECT`). The project
|
||||
must already exist. The suite creates it only in a tracker it owns end to end (the CalDAV
|
||||
server it started); in a profile target, or in Super Productivity, a missing marker is an
|
||||
abort before the first write - Super Productivity's API has no way to create a project anyway.
|
||||
- Every item it creates starts with `[live-test <run id>]`. On the way out it closes what is
|
||||
left of that prefix, and only that prefix.
|
||||
- It never deletes anything. The only closing write is `task close`, the one this stack has.
|
||||
- The headless Super Productivity refuses to start when anything already answers on the fixed
|
||||
API port 3876: an answering app is somebody's real one.
|
||||
- A profile path that starts with `~` is refused. Profiles carry absolute paths, because a `~`
|
||||
resolves against whichever `HOME` the run has.
|
||||
|
||||
## Run it against a tracker of your own
|
||||
|
||||
Do this before you change an adapter for a tracker you actually use, or when a user reports one
|
||||
that fails against theirs. The steps differ per tracker only in the profile and the marker.
|
||||
|
||||
1. **Create the marker project in that tracker**, by hand, named exactly `Chemenu Live-Test`.
|
||||
Nothing else in the tracker is touched, but this project's items are created and closed.
|
||||
2. **Write the profile** to `.wikitool-tasks.d/<name>.json` (gitignored - it holds
|
||||
credentials). It is a normal `.wikitool-tasks.json` plus one optional key,
|
||||
`live_test_version`, which is stripped before use and only names the tracker's version in
|
||||
the report line. Paths are absolute.
|
||||
|
||||
Super Productivity, API access (the app must be running with the Local REST API on):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"provider": "superproductivity",
|
||||
"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5},
|
||||
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:3876", "api_token": "<token>"},
|
||||
"live_test_version": "19.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
CalDAV:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"provider": "caldav",
|
||||
"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5},
|
||||
"caldav": {"url": "https://<server>/<user>/", "username": "<user>", "app_password": "<password>",
|
||||
"inbox_list": "Inbox", "someday_list": "Someday"},
|
||||
"live_test_version": "<server and version>"
|
||||
}
|
||||
```
|
||||
|
||||
A Super Productivity profile with `access: "snapshot"` is accepted too, but the workflow
|
||||
needs a write path, so the scenario fails on it by design: that access is read-only.
|
||||
3. **Run it**, from `tools/`:
|
||||
|
||||
```bash
|
||||
CHEMENU_LIVE_PROFILE=<name> CHEMENU_LIVE_REQUIRE=profile \
|
||||
.venv/bin/python -m pytest -m live_tracker -k profile -s
|
||||
```
|
||||
|
||||
`-s` shows the report line with the tracker version, which is what you paste into an issue.
|
||||
4. **Read a failure as a finding about that tracker**, not as a flaky test: the scenario is
|
||||
deterministic. Reproduce it with `task` and `review` under `WIKITOOL_TASKS_CONFIG`, then fix
|
||||
the adapter and add a recorded fixture for the answer that broke it.
|
||||
|
||||
## The two workflows and the image
|
||||
|
||||
| Workflow | When | What |
|
||||
|---|---|---|
|
||||
| `ci.yml`, step "Live tracker suite (CalDAV)" | Every push and PR | Radicale as a process on loopback (`pip install radicale`), `CHEMENU_LIVE_REQUIRE=caldav`. The one half that needs no app and no display. |
|
||||
| `tracker-live.yml` | Nightly 05:00 UTC, and by hand | Both kinds, `CHEMENU_LIVE_REQUIRE=sp,caldav`, inside the `chemenu-sp-live` image. It logs the installed Super Productivity version and warns when it differs from the update channel. |
|
||||
| `sp-live-image.yml` | Daily 04:10 UTC, and by hand | Builds `gitea.nehmer.net/torben/chemenu-sp-live` when the registry lacks the channel's current version, and once a month regardless. |
|
||||
|
||||
The image follows the update channel (`latest-linux.yml`, resolved by
|
||||
`.gitea/sp-live/resolve-version.sh`) and not a pin: installed desktop clients update
|
||||
themselves, and a pinned old version would be tested while users run the new one. Tags are
|
||||
`:<version>` and `:latest`; a manual build with an explicit `sp_version` never moves `:latest`,
|
||||
which is how you reproduce a red night against the version it went red on.
|
||||
|
||||
**The first push of the package needs one manual step**, which no workflow can do: link the
|
||||
package `chemenu-sp-live` to the repository `torben/chemenu` in the Gitea UI, once, and
|
||||
dispatch `sp-live-image.yml` first - `tracker-live.yml` has nothing to pull before that. The
|
||||
package is public, so the runner pulls it anonymously.
|
||||
|
||||
## When an agent triggers the nightly run
|
||||
|
||||
A session that changed the Super Productivity surface does not wait for the clock: after the
|
||||
push has landed and CI is green, dispatch `tracker-live.yml` and poll it (Gitea MCP,
|
||||
`actions_run_write` then `actions_run_read`; never an anonymous `curl`). The surface is
|
||||
`tools/chemenu/tasks/superproductivity.py`, `tools/chemenu/tasks/config.py`,
|
||||
`tools/chemenu/tests/sp_headless.py`, `tools/chemenu/tests/tracker_live.py`, the fixtures under
|
||||
`tools/chemenu/tests/fixtures/sp/`, and `.gitea/sp-live/`. A change to the CalDAV adapter needs
|
||||
no dispatch - `ci.yml` already ran it.
|
||||
|
||||
Say in the session summary which run answered and against which Super Productivity version.
|
||||
|
||||
## A red night
|
||||
|
||||
The nightly run is red for one of three reasons, and they are told apart by the log:
|
||||
|
||||
1. **The app changed.** The report line names a Super Productivity version newer than the last
|
||||
green night. This is the finding the workflow exists for: the adapter must follow. Re-record
|
||||
the fixtures (below), fix the adapter, and open an issue for it with the two version
|
||||
numbers.
|
||||
2. **The image is stale.** The step "Which Super Productivity is this" warned that the
|
||||
installed version differs from the channel. Dispatch `sp-live-image.yml` with `force`, then
|
||||
the run again.
|
||||
3. **The environment.** The app did not start (the abort message carries the last lines of
|
||||
the app's own log), or the runner could not pull the image. Nothing about the adapter is known yet.
|
||||
|
||||
Never skip the suite, never remove `CHEMENU_LIVE_REQUIRE` from a workflow and never mark the
|
||||
job `continue-on-error` to get a green night: a red night with a cause is the product.
|
||||
|
||||
## Refreshing the fixtures
|
||||
|
||||
`tools/chemenu/tests/fixtures/sp/api/*.json` are the raw answers of a real Super Productivity;
|
||||
`seed-backup.json` is a backup the app wrote itself, with the three projects the live suite and
|
||||
the demo pages use. `MANIFEST.json` says which version and when. Refresh them when a red night
|
||||
shows a new answer shape, and when the manifest's version is more than a few releases behind.
|
||||
|
||||
1. Get a Super Productivity to record from: the current `chemenu-sp-live` image with the
|
||||
checkout mounted (`docker run --rm -v "$PWD:/work" -w /work/tools ...`, then a venv from
|
||||
`requirements.txt` inside it), or any machine with the `.deb` installed and
|
||||
`CHEMENU_LIVE_SP_BINARY` set.
|
||||
2. From `tools/`, in an environment that has `requirements.txt`:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m chemenu.tests.record_sp_fixtures chemenu/tests/fixtures/sp/api
|
||||
```
|
||||
|
||||
3. Update `MANIFEST.json` (`sp_version`, `recorded`) by hand - it is a fixture, not a generated
|
||||
page - and run `test_sp_recorded.py`. If the seed backup is stale because the app's backup
|
||||
format moved, rebuild it from a backup the new version wrote and keep the three projects.
|
||||
4. Do not edit the recorded answers to make a test pass. A test that fails against a fresh
|
||||
recording is the adapter's problem.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Decide which kind of change this is.** A change to an adapter's parsing: extend the recorded
|
||||
fixtures and their replay test first. A change that alters what the adapter sends: the live
|
||||
suite has to run, so run it against a tracker (above) before publishing.
|
||||
2. **Run the offline half** with the rest of the suite; it includes the safety tests for the
|
||||
guard, the profile rules and the port refusal.
|
||||
3. **Run the live half** for the tracker you changed - CalDAV locally with a Radicale of your
|
||||
own, Super Productivity in the image or with a profile.
|
||||
4. **After the push, dispatch `tracker-live.yml`** when the change is on the Super Productivity
|
||||
surface (see [When an agent triggers the nightly run](#when-an-agent-triggers-the-nightly-run)).
|
||||
|
||||
## Scope
|
||||
|
||||
Applies to the task-tracker adapters under `tools/chemenu/tasks/` and to the live suite
|
||||
itself. It is the one place the test conventions are relaxed on purpose:
|
||||
[testing-conventions.md](testing-conventions.md) describes the hermetic default run, and the
|
||||
live suite is opt-in, marked `live_tracker`, and may start a real application and talk to a
|
||||
real server. The default run stays free of both.
|
||||
+8
-1
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
type: types/project.md
|
||||
state: active
|
||||
responsibility: technik
|
||||
created: 2026-09-30
|
||||
modified: 2026-09-30
|
||||
related:
|
||||
- see-also: Chemenu
|
||||
sources: []
|
||||
provenance: general
|
||||
summary: Aus dem laufenden Kandidaten 8.0.0-beta wird das Release 8.0.0.
|
||||
---
|
||||
# Chemenu 8.0.0 freigeben
|
||||
|
||||
**Status:** Active
|
||||
**Bereich:** Technik
|
||||
|
||||
## Ziel
|
||||
|
||||
Aus dem laufenden Kandidaten 8.0.0-beta wird das Release 8.0.0: veröffentlicht, mit vollständigem Changelog-Eintrag, und eine bestehende Instanz kann per `dist upgrade` darauf wechseln.
|
||||
|
||||
## Kontext
|
||||
|
||||
Zwischen zwei Releases führt [[Chemenu]] als Stack einen einzigen laufenden Kandidaten mit `-beta.N`-Suffix statt einer neuen Version je Änderung. Der Schritt vom Kandidaten zum Release ist bewusst eine menschliche Entscheidung und kein Agentenschritt.
|
||||
|
||||
## Beteiligte
|
||||
|
||||
Torben Nehmer gibt frei; ein Agent bereitet die Kandidaten vor und schreibt den Changelog.
|
||||
|
||||
## Status
|
||||
|
||||
Der Kandidat trägt die Änderungen der Installationsüberarbeitung und der Tracker-Anbindung. Ein Release ist noch nicht gefallen.
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
- Die Freigabe selbst (`version release`) führt kein Agent aus.
|
||||
|
||||
## Gelerntes
|
||||
|
||||
Ein Test gegen aufgezeichnete Antworten hält den Parser ehrlich, sagt aber nichts darüber, ob der Tracker heute noch so antwortet - dafür braucht es einen Lauf gegen das echte Programm.
|
||||
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Chemenu]]
|
||||
<!-- /wikitool:links -->
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: types/project.md
|
||||
state: active
|
||||
responsibility: technik
|
||||
created: 2026-09-30
|
||||
modified: 2026-09-30
|
||||
related:
|
||||
- see-also: Chemenu
|
||||
sources: []
|
||||
provenance: general
|
||||
summary: Chemenu läuft auf Windows nativ unter PowerShell 7, ohne WSL und ohne Windows PowerShell 5.1.
|
||||
---
|
||||
# Windows nativ unterstützen
|
||||
|
||||
**Status:** Active
|
||||
**Bereich:** Technik
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein Windows-Nutzer installiert und betreibt Chemenu nativ - unter PowerShell 7, ohne WSL und ohne Windows PowerShell 5.1 - und der Installationsweg führt ihn ohne Handarbeit an der Distribution selbst zur laufenden Instanz.
|
||||
|
||||
## Kontext
|
||||
|
||||
Die Installation von [[Chemenu]] war bisher auf Linux und macOS zugeschnitten. Mit der Überarbeitung der Installation (Gitea-Issue 140) wird Windows kein Sonderfall am Rand mehr, sondern ein gleichberechtigter Zielpfad. Als Installationsweg bleibt der Release-Download (Weg A) übrig.
|
||||
|
||||
## Beteiligte
|
||||
|
||||
Torben Nehmer entscheidet über Umfang und Reihenfolge.
|
||||
|
||||
## Status
|
||||
|
||||
Der Zuschnitt ist entschieden: PowerShell 7 ist die Untergrenze, WSL und Windows PowerShell 5.1 sind ausdrücklich ausgeschlossen. Vor dem Setup prüft ein Preflight die Voraussetzungen; fehlt eine, bricht er mit Exit-Code 42 ab und der Nutzer sieht die Ausgabe, bevor irgendetwas weiterläuft.
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
- Nur Weg A, der Release-Download - eine zweite Windows-Sonderbahn wäre eine zweite Kopie der Regeln.
|
||||
- Kein Agent installiert Abhängigkeiten: was fehlt, meldet der Preflight, und der Nutzer entscheidet.
|
||||
- PowerShell 7 ist die einzige unterstützte Shell; Windows PowerShell 5.1 und WSL sind kein Zielpfad.
|
||||
|
||||
## Gelerntes
|
||||
|
||||
Pfade, die ein Test auf einem Linux-Rechner nie sieht - Laufwerksbuchstaben, Groß- und Kleinschreibung, Zeichen, die in einem Dateinamen verboten sind - fallen erst auf, wenn die Regel dafür im Werkzeug selbst steht. Seitentitel müssen deshalb als Dateinamen auf Windows und macOS gültig sein.
|
||||
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Chemenu]]
|
||||
<!-- /wikitool:links -->
|
||||
+10
-4
@@ -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 |
|
||||
|
||||
+13
-2
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 `<root>/.wikitool-tasks.json`
|
||||
# (Gitea #156), so one checkout can be run against several trackers one after the
|
||||
# other without swapping a file. Registered in `_WIKITOOL_ENV`
|
||||
# (tools/chemenu/tests/conftest.py). A relative value is taken from the working
|
||||
# directory. Set but pointing at nothing is an error, never "no tracker".
|
||||
ENV_TASKS_CONFIG = "WIKITOOL_TASKS_CONFIG"
|
||||
|
||||
|
||||
def tasks_config_override() -> "Path | None":
|
||||
"""The file `$WIKITOOL_TASKS_CONFIG` names, or `None` when it is unset or blank."""
|
||||
value = os.environ.get(ENV_TASKS_CONFIG, "").strip()
|
||||
return Path(value).expanduser().resolve() if value else None
|
||||
|
||||
|
||||
def tasks_config_path(root: "Path | str") -> Path:
|
||||
"""The tracker configuration file this process reads: the override when set,
|
||||
else `<root>/.wikitool-tasks.json`."""
|
||||
override = tasks_config_override()
|
||||
return override if override is not None else Path(root) / TASKS_CONFIG_FILENAME
|
||||
|
||||
|
||||
def tasks_config_label(root: "Path | str") -> str:
|
||||
"""How a message names the file `tasks_config_path` reads - the bare file
|
||||
name by default, the actual path and the variable when the override is on."""
|
||||
override = tasks_config_override()
|
||||
if override is None:
|
||||
return TASKS_CONFIG_FILENAME
|
||||
return f"{override} (from {ENV_TASKS_CONFIG})"
|
||||
|
||||
|
||||
def default_author() -> str | None:
|
||||
"""The author to stamp a new source page with, per instance.
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"data": {
|
||||
"rendererReady": true,
|
||||
"server": "up"
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"advancedCfg": {
|
||||
"worklogExportSettings": {
|
||||
"cols": [
|
||||
"DATE",
|
||||
"START",
|
||||
"END",
|
||||
"TIME_CLOCK",
|
||||
"TITLES_INCLUDING_SUB"
|
||||
],
|
||||
"groupBy": "DATE",
|
||||
"roundEndTimeTo": null,
|
||||
"roundStartTimeTo": null,
|
||||
"roundWorkTimeTo": null,
|
||||
"separateTasksBy": " | "
|
||||
}
|
||||
},
|
||||
"backlogTaskIds": [],
|
||||
"doneOn": null,
|
||||
"icon": "inbox",
|
||||
"id": "INBOX_PROJECT",
|
||||
"isArchived": false,
|
||||
"isDone": false,
|
||||
"isEnableBacklog": false,
|
||||
"isHiddenFromMenu": false,
|
||||
"noteIds": [],
|
||||
"taskIds": [
|
||||
"cbN7F3EMo67k9RrfMjS26",
|
||||
"KfvaOD_bl7mIn-Fq0W9NE"
|
||||
],
|
||||
"theme": {
|
||||
"accent": "#ff4081",
|
||||
"backgroundImageBlur": 0,
|
||||
"backgroundImageDark": "",
|
||||
"backgroundImageLight": null,
|
||||
"backgroundOverlayOpacity": 20,
|
||||
"hueAccent": "500",
|
||||
"huePrimary": "500",
|
||||
"hueWarn": "500",
|
||||
"isAutoContrast": true,
|
||||
"isDisableBackgroundTint": false,
|
||||
"primary": "rgb(144, 187, 165)",
|
||||
"warn": "#e11826"
|
||||
},
|
||||
"title": "Inbox"
|
||||
},
|
||||
{
|
||||
"advancedCfg": {
|
||||
"worklogExportSettings": {
|
||||
"cols": [
|
||||
"DATE",
|
||||
"START",
|
||||
"END",
|
||||
"TIME_CLOCK",
|
||||
"TITLES_INCLUDING_SUB"
|
||||
],
|
||||
"groupBy": "DATE",
|
||||
"roundEndTimeTo": null,
|
||||
"roundStartTimeTo": null,
|
||||
"roundWorkTimeTo": null,
|
||||
"separateTasksBy": " | "
|
||||
}
|
||||
},
|
||||
"backlogTaskIds": [],
|
||||
"created": 1790745002886,
|
||||
"doneOn": null,
|
||||
"icon": null,
|
||||
"id": "chemenu-live-test",
|
||||
"isArchived": false,
|
||||
"isDone": false,
|
||||
"isEnableBacklog": false,
|
||||
"isHiddenFromMenu": false,
|
||||
"noteIds": [],
|
||||
"taskIds": [
|
||||
"gwedT3TNeJm35AaKMj_Ov",
|
||||
"dCf43Dr1Ei9Gc1DxysY5q",
|
||||
"EBm18ZV1F1LrVV7hsauGu"
|
||||
],
|
||||
"theme": {
|
||||
"accent": "#ff4081",
|
||||
"backgroundImageBlur": 0,
|
||||
"backgroundImageDark": "",
|
||||
"backgroundImageLight": null,
|
||||
"backgroundOverlayOpacity": 20,
|
||||
"hueAccent": "500",
|
||||
"huePrimary": "500",
|
||||
"hueWarn": "500",
|
||||
"isAutoContrast": true,
|
||||
"isDisableBackgroundTint": false,
|
||||
"primary": "rgb(144, 187, 165)",
|
||||
"warn": "#e11826"
|
||||
},
|
||||
"title": "Chemenu Live-Test"
|
||||
},
|
||||
{
|
||||
"advancedCfg": {
|
||||
"worklogExportSettings": {
|
||||
"cols": [
|
||||
"DATE",
|
||||
"START",
|
||||
"END",
|
||||
"TIME_CLOCK",
|
||||
"TITLES_INCLUDING_SUB"
|
||||
],
|
||||
"groupBy": "DATE",
|
||||
"roundEndTimeTo": null,
|
||||
"roundStartTimeTo": null,
|
||||
"roundWorkTimeTo": null,
|
||||
"separateTasksBy": " | "
|
||||
}
|
||||
},
|
||||
"backlogTaskIds": [],
|
||||
"created": 1790745002886,
|
||||
"doneOn": null,
|
||||
"icon": null,
|
||||
"id": "demo-1",
|
||||
"isArchived": false,
|
||||
"isDone": false,
|
||||
"isEnableBacklog": false,
|
||||
"isHiddenFromMenu": false,
|
||||
"noteIds": [],
|
||||
"taskIds": [],
|
||||
"theme": {
|
||||
"accent": "#ff4081",
|
||||
"backgroundImageBlur": 0,
|
||||
"backgroundImageDark": "",
|
||||
"backgroundImageLight": null,
|
||||
"backgroundOverlayOpacity": 20,
|
||||
"hueAccent": "500",
|
||||
"huePrimary": "500",
|
||||
"hueWarn": "500",
|
||||
"isAutoContrast": true,
|
||||
"isDisableBackgroundTint": false,
|
||||
"primary": "rgb(144, 187, 165)",
|
||||
"warn": "#e11826"
|
||||
},
|
||||
"title": "Chemenu 8.0.0 freigeben"
|
||||
},
|
||||
{
|
||||
"advancedCfg": {
|
||||
"worklogExportSettings": {
|
||||
"cols": [
|
||||
"DATE",
|
||||
"START",
|
||||
"END",
|
||||
"TIME_CLOCK",
|
||||
"TITLES_INCLUDING_SUB"
|
||||
],
|
||||
"groupBy": "DATE",
|
||||
"roundEndTimeTo": null,
|
||||
"roundStartTimeTo": null,
|
||||
"roundWorkTimeTo": null,
|
||||
"separateTasksBy": " | "
|
||||
}
|
||||
},
|
||||
"backlogTaskIds": [],
|
||||
"created": 1790745002886,
|
||||
"doneOn": null,
|
||||
"icon": null,
|
||||
"id": "demo-2",
|
||||
"isArchived": false,
|
||||
"isDone": false,
|
||||
"isEnableBacklog": false,
|
||||
"isHiddenFromMenu": false,
|
||||
"noteIds": [],
|
||||
"taskIds": [],
|
||||
"theme": {
|
||||
"accent": "#ff4081",
|
||||
"backgroundImageBlur": 0,
|
||||
"backgroundImageDark": "",
|
||||
"backgroundImageLight": null,
|
||||
"backgroundOverlayOpacity": 20,
|
||||
"hueAccent": "500",
|
||||
"huePrimary": "500",
|
||||
"hueWarn": "500",
|
||||
"isAutoContrast": true,
|
||||
"isDisableBackgroundTint": false,
|
||||
"primary": "rgb(144, 187, 165)",
|
||||
"warn": "#e11826"
|
||||
},
|
||||
"title": "Windows nativ unterst\u00fctzen"
|
||||
}
|
||||
],
|
||||
"ok": true
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"advancedCfg": {
|
||||
"worklogExportSettings": {
|
||||
"cols": [
|
||||
"DATE",
|
||||
"START",
|
||||
"END",
|
||||
"TIME_CLOCK",
|
||||
"TITLES_INCLUDING_SUB"
|
||||
],
|
||||
"groupBy": "DATE",
|
||||
"roundEndTimeTo": null,
|
||||
"roundStartTimeTo": null,
|
||||
"roundWorkTimeTo": null,
|
||||
"separateTasksBy": " | "
|
||||
}
|
||||
},
|
||||
"color": null,
|
||||
"created": 1790744929943,
|
||||
"icon": "wb_sunny",
|
||||
"id": "TODAY",
|
||||
"taskIds": [
|
||||
"cbN7F3EMo67k9RrfMjS26",
|
||||
"gwedT3TNeJm35AaKMj_Ov",
|
||||
"EBm18ZV1F1LrVV7hsauGu",
|
||||
"KfvaOD_bl7mIn-Fq0W9NE"
|
||||
],
|
||||
"theme": {
|
||||
"accent": "#ff4081",
|
||||
"backgroundImageBlur": 0,
|
||||
"backgroundImageDark": "",
|
||||
"backgroundImageLight": null,
|
||||
"backgroundOverlayOpacity": 20,
|
||||
"hueAccent": "500",
|
||||
"huePrimary": "400",
|
||||
"hueWarn": "500",
|
||||
"isAutoContrast": true,
|
||||
"isDisableBackgroundTint": true,
|
||||
"primary": "#6495ED",
|
||||
"warn": "#e11826"
|
||||
},
|
||||
"title": "Today"
|
||||
},
|
||||
{
|
||||
"advancedCfg": {
|
||||
"worklogExportSettings": {
|
||||
"cols": [
|
||||
"DATE",
|
||||
"START",
|
||||
"END",
|
||||
"TIME_CLOCK",
|
||||
"TITLES_INCLUDING_SUB"
|
||||
],
|
||||
"groupBy": "DATE",
|
||||
"roundEndTimeTo": null,
|
||||
"roundStartTimeTo": null,
|
||||
"roundWorkTimeTo": null,
|
||||
"separateTasksBy": " | "
|
||||
}
|
||||
},
|
||||
"color": null,
|
||||
"created": 1790745002886,
|
||||
"icon": null,
|
||||
"id": "tag-waiting",
|
||||
"taskIds": [
|
||||
"dCf43Dr1Ei9Gc1DxysY5q"
|
||||
],
|
||||
"theme": {
|
||||
"accent": "#ff4081",
|
||||
"backgroundImageBlur": 0,
|
||||
"backgroundImageDark": "",
|
||||
"backgroundImageLight": null,
|
||||
"backgroundOverlayOpacity": 20,
|
||||
"hueAccent": "500",
|
||||
"huePrimary": "400",
|
||||
"hueWarn": "500",
|
||||
"isAutoContrast": true,
|
||||
"isDisableBackgroundTint": true,
|
||||
"primary": "#6495ED",
|
||||
"warn": "#e11826"
|
||||
},
|
||||
"title": "waiting"
|
||||
}
|
||||
],
|
||||
"ok": true
|
||||
}
|
||||
+64
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"task": {"ids": ["KfvaOD_bl7mIn-Fq0W9NE"], "entities": {"KfvaOD_bl7mIn-Fq0W9NE": {"id": "KfvaOD_bl7mIn-Fq0W9NE", "subTaskIds": [], "timeSpentOnDay": {}, "timeSpent": 0, "timeEstimate": 0, "isDone": false, "title": "seed marker task", "tagIds": [], "created": 1790744930824, "attachments": [], "projectId": "INBOX_PROJECT", "dueDay": "2026-09-30"}}, "currentTaskId": null, "selectedTaskId": null, "taskDetailTargetPanel": null, "lastCurrentTaskId": null, "isDataLoaded": false, "dismissedCalendarAutoImportEventIdsByProvider": {}}, "project": {"ids": ["INBOX_PROJECT", "chemenu-live-test", "demo-1", "demo-2"], "entities": {"INBOX_PROJECT": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": ["KfvaOD_bl7mIn-Fq0W9NE"], "icon": "inbox", "id": "INBOX_PROJECT", "title": "Inbox"}, "chemenu-live-test": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "chemenu-live-test", "title": "Chemenu Live-Test", "created": 1790745002886}, "demo-1": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "demo-1", "title": "Chemenu 8.0.0 freigeben", "created": 1790745002886}, "demo-2": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "demo-2", "title": "Windows nativ unterst\u00fctzen", "created": 1790745002886}}}, "tag": {"ids": ["TODAY", "tag-waiting"], "entities": {"TODAY": {"color": null, "created": 1790744929943, "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": true, "primary": "#6495ED", "huePrimary": "400", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": ["KfvaOD_bl7mIn-Fq0W9NE"], "icon": "wb_sunny", "id": "TODAY", "title": "Today"}, "tag-waiting": {"color": null, "created": 1790745002886, "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": trLine truncated
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Re-record `fixtures/sp/api/*.json` from a real Super Productivity (Gitea #156).
|
||||
|
||||
Not a test module. Run it where the packaged app is installed - the `chemenu-sp-live`
|
||||
image, or any machine with `CHEMENU_LIVE_SP_BINARY` set - from `tools/`:
|
||||
|
||||
CHEMENU_LIVE_SP_BINARY=... python -m chemenu.tests.record_sp_fixtures <out-dir>
|
||||
|
||||
It starts the seeded headless app, creates four items and closes one through wikitool's own
|
||||
writer, then stores the raw answers of `GET /health`, `/projects`, `/tasks` and `/tags`.
|
||||
`instructions/dev/tracker-testing.md` says when to run it and what to update beside it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from chemenu.tasks import superproductivity as sp
|
||||
from chemenu.tests import sp_headless
|
||||
|
||||
PROJECT = "Chemenu Live-Test"
|
||||
|
||||
|
||||
def main(out: Path) -> None:
|
||||
binary = os.environ.get("CHEMENU_LIVE_SP_BINARY")
|
||||
if not binary:
|
||||
sys.exit("CHEMENU_LIVE_SP_BINARY names no Super Productivity binary")
|
||||
executable = Path(binary)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
work = Path(tempfile.mkdtemp(prefix="chemenu-record-sp-"))
|
||||
process = sp_headless.start(executable, work / "profile", marker_project=PROJECT)
|
||||
try:
|
||||
cfg = sp.SuperProductivityConfig.from_dict(
|
||||
{"access": "api", "api_base_url": process.base_url, "api_token": process.token}
|
||||
)
|
||||
reader = sp.SuperProductivityApiReader(cfg)
|
||||
writer = sp.SuperProductivityWriter(cfg, reader)
|
||||
writer.create_item(
|
||||
"Recorded: offene Aufgabe", project_name=PROJECT,
|
||||
notes="kb/gtd/technik/Chemenu Live-Test.md",
|
||||
)
|
||||
writer.create_item(
|
||||
"Recorded: warte auf Antwort", project_name=PROJECT,
|
||||
waiting=True, follow_up_at=date(2026, 8, 1),
|
||||
)
|
||||
writer.create_item("Recorded: erledigt", project_name=PROJECT)
|
||||
writer.create_item("Recorded: Posteingang", project_name=None)
|
||||
done = next(i for i in reader.open_items(PROJECT).items if i.title == "Recorded: erledigt")
|
||||
writer.close_item(done.id)
|
||||
|
||||
def get(path: str, auth: bool) -> object:
|
||||
headers = {"Authorization": f"Bearer {process.token}"} if auth else {}
|
||||
request = urllib.request.Request(process.base_url + path, headers=headers)
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
for name, path, auth in (
|
||||
("health", "/health", False), ("projects", "/projects", True),
|
||||
("tasks", "/tasks", True), ("tags", "/tags", True),
|
||||
):
|
||||
(out / f"{name}.json").write_text(
|
||||
json.dumps(get(path, auth), indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
print("recorded against Super Productivity", sp_headless.installed_version(executable))
|
||||
finally:
|
||||
process.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit("usage: python -m chemenu.tests.record_sp_fixtures <out-dir>")
|
||||
main(Path(sys.argv[1]))
|
||||
@@ -0,0 +1,305 @@
|
||||
"""A throwaway Super Productivity for the live tracker suite (Gitea #156).
|
||||
|
||||
Not a test module. `tracker_live` uses it to start the packaged desktop app with no
|
||||
visible display, seeded from `fixtures/sp/seed-backup.json`, and to stop it again.
|
||||
Everything here was found out by running the real app (v19.1.0, see
|
||||
`instructions/dev/tracker-testing.md`):
|
||||
|
||||
- the Local REST API exists only when `SP_FORCE_LOCAL_REST_API=1` is set, listens on
|
||||
the fixed port 3876 and takes the token from `SP_FORCE_LOCAL_REST_API_TOKEN`;
|
||||
- a packaged app needs `NODE_ENV=DEV` plus `--custom-url` at its own bundled
|
||||
`index.html`, or it opens a remote page;
|
||||
- data comes in through a backup file in `<profile>/backups/`, and the app asks
|
||||
"restore the newest backup?" with a `window.confirm` at startup, which has to be
|
||||
accepted over the Chromium DevTools protocol - there is no flag for it.
|
||||
|
||||
Standard library only: the suite must run wherever `tools/requirements.txt` does.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
API_PORT = 3876
|
||||
CDP_PORT = 9222
|
||||
SEED_BACKUP = Path(__file__).parent / "fixtures" / "sp" / "seed-backup.json"
|
||||
|
||||
|
||||
class LiveAbort(RuntimeError):
|
||||
"""The live suite refuses to touch a tracker. Raised before the first write."""
|
||||
|
||||
|
||||
def health(port: int = API_PORT, timeout: float = 2.0) -> Optional[dict]:
|
||||
"""`GET /health` unwrapped, or `None` when nothing (Super Productivity) answers."""
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
except (OSError, ValueError, urllib.error.URLError):
|
||||
return None
|
||||
if not isinstance(payload, dict) or not payload.get("ok"):
|
||||
return None
|
||||
data = payload.get("data")
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
# --- a minimal WebSocket client, just enough for the DevTools protocol -----------------
|
||||
|
||||
|
||||
def _ws_connect(host: str, port: int, path: str, timeout: float) -> socket.socket:
|
||||
sock = socket.create_connection((host, port), timeout=timeout)
|
||||
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
||||
sock.sendall(
|
||||
(
|
||||
f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||
).encode("ascii")
|
||||
)
|
||||
buffer = b""
|
||||
while b"\r\n\r\n" not in buffer:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
raise OSError("DevTools closed the connection during the handshake")
|
||||
buffer += chunk
|
||||
if b" 101 " not in buffer.split(b"\r\n", 1)[0]:
|
||||
raise OSError(f"DevTools refused the WebSocket upgrade: {buffer[:80]!r}")
|
||||
return sock
|
||||
|
||||
|
||||
def _ws_send(sock: socket.socket, text: str) -> None:
|
||||
payload = text.encode("utf-8")
|
||||
mask = os.urandom(4)
|
||||
length = len(payload)
|
||||
if length < 126:
|
||||
header = struct.pack("!BB", 0x81, 0x80 | length)
|
||||
elif length < 65536:
|
||||
header = struct.pack("!BBH", 0x81, 0x80 | 126, length)
|
||||
else:
|
||||
header = struct.pack("!BBQ", 0x81, 0x80 | 127, length)
|
||||
masked = bytes(byte ^ mask[i % 4] for i, byte in enumerate(payload))
|
||||
sock.sendall(header + mask + masked)
|
||||
|
||||
|
||||
def _recv_exact(sock: socket.socket, count: int) -> bytes:
|
||||
data = b""
|
||||
while len(data) < count:
|
||||
chunk = sock.recv(count - len(data))
|
||||
if not chunk:
|
||||
raise OSError("DevTools closed the connection")
|
||||
data += chunk
|
||||
return data
|
||||
|
||||
|
||||
def _ws_recv(sock: socket.socket) -> Optional[str]:
|
||||
"""The next text message, or `None` on a close frame. Server frames are unmasked."""
|
||||
while True:
|
||||
first, second = _recv_exact(sock, 2)
|
||||
opcode = first & 0x0F
|
||||
length = second & 0x7F
|
||||
if length == 126:
|
||||
(length,) = struct.unpack("!H", _recv_exact(sock, 2))
|
||||
elif length == 127:
|
||||
(length,) = struct.unpack("!Q", _recv_exact(sock, 8))
|
||||
payload = _recv_exact(sock, length)
|
||||
if opcode == 0x8:
|
||||
return None
|
||||
if opcode in (0x1, 0x0):
|
||||
return payload.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _accept_dialogs(cdp_port: int, stop: threading.Event, log: list[str]) -> None:
|
||||
"""Accept every JavaScript dialog the renderer opens until `stop` is set."""
|
||||
while not stop.is_set():
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{cdp_port}/json", timeout=2) as resp:
|
||||
targets = [t for t in json.loads(resp.read()) if t.get("type") == "page"]
|
||||
if not targets:
|
||||
raise OSError("no page target yet")
|
||||
ws_url = targets[0]["webSocketDebuggerUrl"]
|
||||
path = "/" + ws_url.split("/", 3)[3]
|
||||
sock = _ws_connect("127.0.0.1", cdp_port, path, timeout=2)
|
||||
except (OSError, ValueError, KeyError, IndexError):
|
||||
stop.wait(0.5)
|
||||
continue
|
||||
try:
|
||||
_ws_send(sock, json.dumps({"id": 1, "method": "Page.enable"}))
|
||||
sock.settimeout(1.0)
|
||||
while not stop.is_set():
|
||||
try:
|
||||
message = _ws_recv(sock)
|
||||
except socket.timeout:
|
||||
continue
|
||||
if message is None:
|
||||
break
|
||||
event = json.loads(message)
|
||||
if event.get("method") == "Page.javascriptDialogOpening":
|
||||
params = event.get("params", {})
|
||||
log.append(f"{params.get('type')}: {str(params.get('message'))[:120]}")
|
||||
_ws_send(sock, json.dumps({
|
||||
"id": 2, "method": "Page.handleJavaScriptDialog",
|
||||
"params": {"accept": True},
|
||||
}))
|
||||
except (OSError, ValueError):
|
||||
stop.wait(0.5)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
# --- the app ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpProcess:
|
||||
process: "subprocess.Popen[bytes]"
|
||||
profile_dir: Path
|
||||
token: str
|
||||
port: int
|
||||
log_path: Path
|
||||
dialogs: list[str]
|
||||
_stop: threading.Event
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f"http://127.0.0.1:{self.port}"
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self.process.poll() is None:
|
||||
try:
|
||||
os.killpg(self.process.pid, signal.SIGTERM)
|
||||
self.process.wait(timeout=15)
|
||||
except (ProcessLookupError, subprocess.TimeoutExpired):
|
||||
try:
|
||||
os.killpg(self.process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def installed_version(executable: Path) -> str:
|
||||
"""The packaged version, for the report line - `unknown` rather than a guess."""
|
||||
override = os.environ.get("CHEMENU_LIVE_SP_VERSION")
|
||||
if override:
|
||||
return override
|
||||
if shutil.which("dpkg-query"):
|
||||
result = subprocess.run(
|
||||
["dpkg-query", "-W", "-f=${Version}", "superproductivity"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _api_projects(base_url: str, token: str) -> list[str]:
|
||||
request = urllib.request.Request(
|
||||
f"{base_url}/projects", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
return [str(p.get("title")) for p in payload.get("data", [])]
|
||||
|
||||
|
||||
def start(
|
||||
executable: Path,
|
||||
profile_dir: Path,
|
||||
*,
|
||||
marker_project: str,
|
||||
port: int = API_PORT,
|
||||
cdp_port: int = CDP_PORT,
|
||||
startup_timeout: float = 240.0,
|
||||
) -> SpProcess:
|
||||
"""Start the packaged app on a fresh, seeded profile and wait until it is ready.
|
||||
|
||||
Refuses when anything already answers on `port`: the port is fixed, so an answering
|
||||
app is somebody's real Super Productivity, and seeding "our" one would either fail
|
||||
or, worse, write into theirs.
|
||||
"""
|
||||
if health(port) is not None:
|
||||
raise LiveAbort(
|
||||
f"something already answers on 127.0.0.1:{port} - a running Super Productivity "
|
||||
"owns the fixed API port, so the live suite will not start a second one next to "
|
||||
"it. Quit that app, or point CHEMENU_LIVE_PROFILE at it deliberately."
|
||||
)
|
||||
if not executable.is_file():
|
||||
raise LiveAbort(f"CHEMENU_LIVE_SP_BINARY does not name a file: {executable}")
|
||||
if not os.environ.get("DISPLAY") and not shutil.which("xvfb-run"):
|
||||
raise LiveAbort("no DISPLAY and no xvfb-run on PATH - the app needs a display to start.")
|
||||
|
||||
backups = profile_dir / "backups"
|
||||
backups.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(SEED_BACKUP, backups / "2026-01-01_000000.json")
|
||||
|
||||
app_dir = executable.parent
|
||||
token = secrets.token_urlsafe(24)
|
||||
env = {
|
||||
**os.environ,
|
||||
"NODE_ENV": "DEV",
|
||||
"SP_FORCE_LOCAL_REST_API": "1",
|
||||
"SP_FORCE_LOCAL_REST_API_TOKEN": token,
|
||||
}
|
||||
command = [
|
||||
str(executable),
|
||||
f"--custom-url=file://{app_dir}/resources/app.asar/.tmp/angular-dist/browser/index.html",
|
||||
f"--user-data-dir={profile_dir}",
|
||||
"--disable-tray", "--no-sandbox",
|
||||
f"--remote-debugging-port={cdp_port}",
|
||||
]
|
||||
if not os.environ.get("DISPLAY"):
|
||||
command = ["xvfb-run", "-a", *command]
|
||||
|
||||
log_path = profile_dir.parent / f"{profile_dir.name}-sp.log"
|
||||
with log_path.open("wb") as log:
|
||||
process = subprocess.Popen(
|
||||
command, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True
|
||||
)
|
||||
stop = threading.Event()
|
||||
dialogs: list[str] = []
|
||||
threading.Thread(
|
||||
target=_accept_dialogs, args=(cdp_port, stop, dialogs), daemon=True
|
||||
).start()
|
||||
handle = SpProcess(process, profile_dir, token, port, log_path, dialogs, stop)
|
||||
|
||||
def log_tail() -> str:
|
||||
try:
|
||||
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return "(no log)"
|
||||
return "\n".join(lines[-25:])
|
||||
|
||||
base_url = handle.base_url
|
||||
deadline = time.monotonic() + startup_timeout
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise LiveAbort(
|
||||
f"Super Productivity exited during startup; log {log_path}:\n{log_tail()}"
|
||||
)
|
||||
state = health(port)
|
||||
if state and state.get("rendererReady") is True:
|
||||
try:
|
||||
if marker_project in _api_projects(base_url, token):
|
||||
return handle
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
time.sleep(2)
|
||||
raise LiveAbort(
|
||||
f"Super Productivity was not ready with project '{marker_project}' within "
|
||||
f"{startup_timeout:.0f}s; log {log_path}:\n{log_tail()}"
|
||||
)
|
||||
except BaseException:
|
||||
handle.stop()
|
||||
raise
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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() == []
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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 ")
|
||||
@@ -0,0 +1,206 @@
|
||||
"""The live tracker suite's targets, guard and scenario (Gitea #156).
|
||||
|
||||
Not a test module - `test_tracker_live.py` runs it. The suite is the one place this
|
||||
repo talks to a real tracker, so its safety rules live in code here rather than in a
|
||||
comment:
|
||||
|
||||
1. Only the project named `MARKER_PROJECT` is ever written to. It must already exist;
|
||||
this module never creates it in a tracker somebody else owns (`guard`).
|
||||
2. Every item it creates carries `ITEM_PREFIX`, which names this run.
|
||||
3. Nothing is ever deleted - the only closing write is `task close`.
|
||||
4. Any violated precondition raises `LiveAbort` *before* the first write.
|
||||
|
||||
A target is one tracker the suite may run against. Three ways to name one, all through
|
||||
the environment, none of them a fixed address:
|
||||
|
||||
- `CHEMENU_LIVE_SP_BINARY`: the packaged Super Productivity; the suite starts its own
|
||||
seeded, headless copy (`sp_headless`).
|
||||
- `CHEMENU_LIVE_CALDAV_URL` + `_USER` + `_PASSWORD`: a CalDAV server the suite may
|
||||
provision the marker calendar in (Radicale in CI).
|
||||
- `CHEMENU_LIVE_PROFILE`: a profile file `.wikitool-tasks.d/<name>.json` in the
|
||||
checkout - a tracker of the user's own, used exactly as configured.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from chemenu import tasks
|
||||
from chemenu.tasks import config as tasks_config
|
||||
from chemenu.tasks.protocol import TaskReader, find_project, normalize_project_name
|
||||
from chemenu.tests.sp_headless import LiveAbort
|
||||
|
||||
MARKER_PROJECT = "Chemenu Live-Test"
|
||||
RUN_ID = uuid.uuid4().hex[:8]
|
||||
ITEM_PREFIX = f"[live-test {RUN_ID}]"
|
||||
PROFILE_DIR = ".wikitool-tasks.d"
|
||||
KINDS = ("sp", "caldav", "profile")
|
||||
THRESHOLDS = {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5}
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveTarget:
|
||||
kind: str
|
||||
label: str
|
||||
version: str
|
||||
config: dict[str, Any]
|
||||
cleanup: Callable[[], None] = field(default=lambda: None)
|
||||
|
||||
|
||||
def required_kinds(environ: "os._Environ[str] | dict[str, str]" = os.environ) -> set[str]:
|
||||
"""Kinds that must run rather than skip (`CHEMENU_LIVE_REQUIRE=caldav,sp`) - CI's way of
|
||||
making a skipped live suite a red one."""
|
||||
raw = environ.get("CHEMENU_LIVE_REQUIRE", "")
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
|
||||
|
||||
def _reject_home_relative(value: Any, where: str) -> None:
|
||||
if isinstance(value, str) and value.startswith("~"):
|
||||
raise LiveAbort(
|
||||
f"{where}: '{value}' starts with '~'. Profiles carry absolute paths only - a "
|
||||
"`~` would resolve against whichever HOME the run happens to have."
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
for key, inner in value.items():
|
||||
_reject_home_relative(inner, f"{where}.{key}")
|
||||
if isinstance(value, list):
|
||||
for index, inner in enumerate(value):
|
||||
_reject_home_relative(inner, f"{where}[{index}]")
|
||||
|
||||
|
||||
def profile_path(name: str) -> Path:
|
||||
if not name or "/" in name or name.startswith("."):
|
||||
raise LiveAbort(f"CHEMENU_LIVE_PROFILE must be a bare profile name, got {name!r}.")
|
||||
return REPO_ROOT / PROFILE_DIR / f"{name}.json"
|
||||
|
||||
|
||||
def load_profile(name: str) -> LiveTarget:
|
||||
path = profile_path(name)
|
||||
if not path.is_file():
|
||||
raise LiveAbort(f"no profile file {path} - see instructions/dev/tracker-testing.md.")
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
_reject_home_relative(data, path.name)
|
||||
return LiveTarget(
|
||||
kind="profile",
|
||||
label=f"profile {name}",
|
||||
version=str(data.get("live_test_version", "unknown")),
|
||||
config={key: value for key, value in data.items() if key != "live_test_version"},
|
||||
)
|
||||
|
||||
|
||||
def caldav_target_from_env(environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
|
||||
url = environ.get("CHEMENU_LIVE_CALDAV_URL")
|
||||
if not url:
|
||||
return None
|
||||
user = environ.get("CHEMENU_LIVE_CALDAV_USER", "")
|
||||
password = environ.get("CHEMENU_LIVE_CALDAV_PASSWORD", "")
|
||||
if not user or not password:
|
||||
raise LiveAbort("CHEMENU_LIVE_CALDAV_URL needs CHEMENU_LIVE_CALDAV_USER and _PASSWORD.")
|
||||
return LiveTarget(
|
||||
kind="caldav",
|
||||
label="CalDAV " + url,
|
||||
version=environ.get("CHEMENU_LIVE_CALDAV_VERSION", "unknown"),
|
||||
config={
|
||||
"schema": 1,
|
||||
"provider": "caldav",
|
||||
"thresholds": THRESHOLDS,
|
||||
"caldav": {
|
||||
"url": url, "username": user, "app_password": password,
|
||||
"inbox_list": "Inbox", "someday_list": "Someday",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def sp_target_from_env(environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
|
||||
binary = environ.get("CHEMENU_LIVE_SP_BINARY")
|
||||
if not binary:
|
||||
return None
|
||||
from chemenu.tests import sp_headless
|
||||
|
||||
executable = Path(binary)
|
||||
workdir = Path(tempfile.mkdtemp(prefix="chemenu-live-sp-"))
|
||||
process = sp_headless.start(executable, workdir / "profile", marker_project=MARKER_PROJECT)
|
||||
|
||||
def cleanup() -> None:
|
||||
process.stop()
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
|
||||
return LiveTarget(
|
||||
kind="sp",
|
||||
label="Super Productivity (headless)",
|
||||
version=sp_headless.installed_version(executable),
|
||||
config={
|
||||
"schema": 1,
|
||||
"provider": "superproductivity",
|
||||
"thresholds": THRESHOLDS,
|
||||
"superproductivity": {
|
||||
"access": "api", "api_base_url": process.base_url, "api_token": process.token,
|
||||
},
|
||||
},
|
||||
cleanup=cleanup,
|
||||
)
|
||||
|
||||
|
||||
def build_target(kind: str, environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
|
||||
if kind == "sp":
|
||||
return sp_target_from_env(environ)
|
||||
if kind == "caldav":
|
||||
return caldav_target_from_env(environ)
|
||||
if kind == "profile":
|
||||
name = environ.get("CHEMENU_LIVE_PROFILE")
|
||||
return load_profile(name) if name else None
|
||||
raise ValueError(f"unknown live tracker kind {kind!r}")
|
||||
|
||||
|
||||
# --- guard and scenario ------------------------------------------------------------------
|
||||
|
||||
|
||||
def reader_for(target_config: dict[str, Any]) -> TaskReader:
|
||||
"""The reader `target_config` describes, built the way the commands build it."""
|
||||
provider = target_config["provider"]
|
||||
cfg = tasks_config.TasksConfig(
|
||||
provider=provider,
|
||||
thresholds=tasks_config.Thresholds(**target_config["thresholds"]),
|
||||
provider_config=target_config[provider],
|
||||
)
|
||||
return tasks.build_reader(cfg)
|
||||
|
||||
|
||||
def guard(reader: TaskReader, *, provision: Optional[Callable[[], None]] = None) -> None:
|
||||
"""Refuse before the first write unless the marker project exists.
|
||||
|
||||
`provision`, given only for a target the suite owns end to end (Radicale in CI), creates
|
||||
the marker first; a profile target never gets one, so a missing marker there is an abort.
|
||||
"""
|
||||
if find_project(reader, MARKER_PROJECT) is None and provision is not None:
|
||||
provision()
|
||||
if find_project(reader, MARKER_PROJECT) is None:
|
||||
raise LiveAbort(
|
||||
f"the tracker has no project named '{MARKER_PROJECT}'. The live suite writes "
|
||||
"only into that project and never creates it in a tracker it does not own - "
|
||||
"create it there first (instructions/dev/tracker-testing.md)."
|
||||
)
|
||||
|
||||
|
||||
def run_id_titles() -> tuple[str, str]:
|
||||
return f"{ITEM_PREFIX} offen", f"{ITEM_PREFIX} warte auf Antwort"
|
||||
|
||||
|
||||
def overdue_day(today: date) -> date:
|
||||
return today - timedelta(days=30)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ITEM_PREFIX", "KINDS", "LiveAbort", "LiveTarget", "MARKER_PROJECT", "THRESHOLDS",
|
||||
"build_target", "guard", "normalize_project_name", "overdue_day", "reader_for",
|
||||
"required_kinds", "run_id_titles",
|
||||
]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user