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
@@ -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",
|
||||
]
|
||||
Reference in new issue
Block a user