From d5b3d3cb382325333bcc60c7b16a70045dd4b437 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 08:24:14 +0200 Subject: [PATCH 1/8] test: add stash and unstash to swap the dev instance and the e2e rig The e2e rig refuses to run over a dev instance at the repo root, and the only way round it was to destroy that instance. The two share only the root config and the container names, not data, so a swap is enough. `stacktests stash` stops the instance's running stacklets, moves `stack.toml`, `users.toml` and `.stack/` to `~/famstack-stash/instance` and brings the rig's config back from `~/famstack-stash/rig`, whose secrets match the test data it left. `stacktests unstash` reverses it and starts what was running. The preserved ai stacklet keeps running throughout. Neither command copies or deletes data. --- tests/integration/stacktests | 96 +++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/tests/integration/stacktests b/tests/integration/stacktests index a00d02d5..5116bed5 100755 --- a/tests/integration/stacktests +++ b/tests/integration/stacktests @@ -19,6 +19,10 @@ # tests/integration/stacktests reset [slet] — destroy + up (clean slate) # tests/integration/stacktests cleanup — nuke every test stacklet + # instance state + test data +# tests/integration/stacktests stash — set the dev instance aside +# and swap the test rig in +# tests/integration/stacktests unstash — swap the rig out and bring +# the dev instance back up # tests/integration/stacktests e2e [args] — run every e2e test # tests/integration/stacktests container-e2e — alias for e2e # tests/integration/stacktests smoke [args] — run smoke e2e tests @@ -75,6 +79,21 @@ REQUIRED_STACKLETS=(core messages docs code) # `stacktests down ai` or `stack destroy ai --yes`. PRESERVED_STACKLETS=(ai) +# Where `stash` parks a dev instance's root config, and the rig's between +# runs. Outside the repo, so no git operation can touch it. +STASH_DIR="${HOME}/famstack-stash" + +# Stacklets of the instance at the repo root that are running, minus the +# preserved ones, comma separated for `stack down` / `stack up`. +running_stacklets() { + stack_cli list --json | "$PYTHON" -c ' +import json, sys +preserved = set(sys.argv[1:]) +ids = [s["id"] for s in json.load(sys.stdin)["stacklets"] + if (s.get("online") or s.get("starting") or s.get("degraded")) and s["id"] not in preserved] +print(",".join(ids))' "${PRESERVED_STACKLETS[@]}" +} + # Bootstrap the repo root as a test instance and seed global secrets. # Fails loudly if the repo root already holds a non-test stack.toml — # see _seed_secrets.py's TestInstanceConflict message for the cleanup @@ -135,6 +154,77 @@ case "${1:-}" in done ;; + stash) + # Set the dev instance at the repo root aside so e2e can use the root. + # + # The rig and a dev instance share only the root config (stack.toml, + # users.toml, .stack/) and the container names. Their data dirs are + # separate (~/famstack-test-data vs the instance's own), so moving + # the three config entries and stopping the containers is the whole + # swap: no data is copied. The rig's config comes back from its own + # slot, so its secrets still match the test data it left behind. + if [[ -f "$REPO_ROOT/.stack/.test-instance" ]]; then + echo "stacktests: the repo root is already the test instance." >&2; exit 1 + fi + if [[ ! -f "$REPO_ROOT/stack.toml" ]]; then + echo "stacktests: no instance at the repo root to stash." >&2; exit 1 + fi + if [[ -e "$STASH_DIR/instance" ]]; then + echo "stacktests: $STASH_DIR/instance already holds a stash." >&2 + echo " Run 'stacktests unstash' first." >&2; exit 1 + fi + + running=$(running_stacklets) + if [[ -n "$running" ]]; then + echo "==> stack down $running" + stack_cli down "$running" + fi + + mkdir -p "$STASH_DIR/instance" + echo "$running" > "$STASH_DIR/instance/running" + mv "$REPO_ROOT/stack.toml" "$REPO_ROOT/users.toml" "$REPO_ROOT/.stack" "$STASH_DIR/instance/" + echo "==> dev instance stashed in $STASH_DIR/instance" + + if [[ -d "$STASH_DIR/rig" ]]; then + mv "$STASH_DIR/rig/stack.toml" "$STASH_DIR/rig/users.toml" "$STASH_DIR/rig/.stack" "$REPO_ROOT/" + rmdir "$STASH_DIR/rig" + echo "==> test rig config restored from $STASH_DIR/rig" + fi + echo "✓ Repo root is free for the rig. 'stacktests unstash' brings the instance back." + ;; + + unstash) + # Reverse of stash: park the rig's config in its slot, put the dev + # instance back and start what was running when it was stashed. + if [[ ! -d "$STASH_DIR/instance" ]]; then + echo "stacktests: nothing stashed in $STASH_DIR/instance." >&2; exit 1 + fi + + if [[ -f "$REPO_ROOT/.stack/.test-instance" ]]; then + running=$(running_stacklets) + if [[ -n "$running" ]]; then + echo "==> stack down $running (test rig)" + stack_cli down "$running" + fi + mkdir -p "$STASH_DIR/rig" + mv "$REPO_ROOT/stack.toml" "$REPO_ROOT/users.toml" "$REPO_ROOT/.stack" "$STASH_DIR/rig/" + echo "==> test rig config kept in $STASH_DIR/rig" + elif [[ -e "$REPO_ROOT/stack.toml" || -e "$REPO_ROOT/.stack" ]]; then + echo "stacktests: the repo root holds an instance that is not the rig." >&2 + echo " Refusing to overwrite it with the stash." >&2; exit 1 + fi + + running=$(cat "$STASH_DIR/instance/running") + mv "$STASH_DIR/instance/stack.toml" "$STASH_DIR/instance/users.toml" "$STASH_DIR/instance/.stack" "$REPO_ROOT/" + rm "$STASH_DIR/instance/running" + rmdir "$STASH_DIR/instance" + echo "==> dev instance restored" + if [[ -n "$running" ]]; then + echo "==> stack up $running" + stack_cli up "$running" + fi + ;; + pytest) # Run pytest with the test-deps extra and the env already set. shift @@ -358,7 +448,11 @@ Autonomy — what an agent may run unattended: repo root is the Simpsons dev rig, not anyone's real famstack. Ports are fixed, so exactly one run at a time: check nothing else is mid-run before starting. - up, down, e2e, smoke, demo-rig, eval + up, down, e2e, smoke, demo-rig, eval, stash, unstash + + e2e needs the root to be the test instance. On a dev + instance, `stash` first and `unstash` after: the swap + moves config only and leaves both data dirs alone. destructive Wipes state that re-running cannot recover. reset, cleanup, down ai From b3ec45860f6094642972aa403efa5198b3fe7aed Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 08:36:24 +0200 Subject: [PATCH 2/8] test: stash the Simpsons demo around e2e and smoke runs `stacktests e2e` and `smoke` now swap the demo instance out when it is at the repo root and swap it back when the run ends, whether it passed, failed or was interrupted. On the rig itself they run as before. `stash` refuses any instance whose `stack.toml` does not name the Matrix server `simpson`, so a family's instance is never stopped for a test run. The name is fixed at install and is part of every user id. --- tests/integration/stacktests | 187 ++++++++++++++++++++++------------- 1 file changed, 117 insertions(+), 70 deletions(-) diff --git a/tests/integration/stacktests b/tests/integration/stacktests index 5116bed5..ec5bc3a4 100755 --- a/tests/integration/stacktests +++ b/tests/integration/stacktests @@ -94,6 +94,111 @@ ids = [s["id"] for s in json.load(sys.stdin)["stacklets"] print(",".join(ids))' "${PRESERVED_STACKLETS[@]}" } +# Only the Simpsons demo is ever swapped out for a test run. Its Matrix +# server name is fixed at install and is in every user id, so a family's +# instance cannot carry it by accident. +DEMO_SERVER_NAME="simpson" + +require_demo_instance() { + local name + name=$("$PYTHON" -c ' +import sys, tomllib +with open(sys.argv[1], "rb") as f: + print(tomllib.load(f).get("messages", {}).get("server_name", ""))' "$REPO_ROOT/stack.toml") + if [[ "$name" != "$DEMO_SERVER_NAME" ]]; then + echo "stacktests: refusing to stash the instance at the repo root." >&2 + echo " stack.toml names the Matrix server '${name:-unset}', not '$DEMO_SERVER_NAME'." >&2 + echo " Tests swap out the Simpsons demo only, never a family's instance." >&2 + exit 1 + fi +} + +# Set the dev instance at the repo root aside so the rig can use the root. +# +# The rig and a dev instance share only the root config (stack.toml, +# users.toml, .stack/) and the container names. Their data dirs are +# separate (~/famstack-test-data vs the instance's own), so moving the +# three config entries and stopping the containers is the whole swap: no +# data is copied. The rig's config comes back from its own slot, so its +# secrets still match the test data it left behind. +stash_instance() { + if [[ -f "$REPO_ROOT/.stack/.test-instance" ]]; then + echo "stacktests: the repo root is already the test instance." >&2; exit 1 + fi + if [[ ! -f "$REPO_ROOT/stack.toml" ]]; then + echo "stacktests: no instance at the repo root to stash." >&2; exit 1 + fi + require_demo_instance + if [[ -e "$STASH_DIR/instance" ]]; then + echo "stacktests: $STASH_DIR/instance already holds a stash." >&2 + echo " Run 'stacktests unstash' first." >&2; exit 1 + fi + + local running + running=$(running_stacklets) + if [[ -n "$running" ]]; then + echo "==> stack down $running" + stack_cli down "$running" + fi + + mkdir -p "$STASH_DIR/instance" + echo "$running" > "$STASH_DIR/instance/running" + mv "$REPO_ROOT/stack.toml" "$REPO_ROOT/users.toml" "$REPO_ROOT/.stack" "$STASH_DIR/instance/" + echo "==> demo instance stashed in $STASH_DIR/instance" + + if [[ -d "$STASH_DIR/rig" ]]; then + mv "$STASH_DIR/rig/stack.toml" "$STASH_DIR/rig/users.toml" "$STASH_DIR/rig/.stack" "$REPO_ROOT/" + rmdir "$STASH_DIR/rig" + echo "==> test rig config restored from $STASH_DIR/rig" + fi +} + +# Reverse of stash: park the rig's config in its slot, put the demo back +# and start what was running when it was stashed. +unstash_instance() { + if [[ ! -d "$STASH_DIR/instance" ]]; then + echo "stacktests: nothing stashed in $STASH_DIR/instance." >&2; exit 1 + fi + + local running + if [[ -f "$REPO_ROOT/.stack/.test-instance" ]]; then + running=$(running_stacklets) + if [[ -n "$running" ]]; then + echo "==> stack down $running (test rig)" + stack_cli down "$running" + fi + mkdir -p "$STASH_DIR/rig" + mv "$REPO_ROOT/stack.toml" "$REPO_ROOT/users.toml" "$REPO_ROOT/.stack" "$STASH_DIR/rig/" + echo "==> test rig config kept in $STASH_DIR/rig" + elif [[ -e "$REPO_ROOT/stack.toml" || -e "$REPO_ROOT/.stack" ]]; then + echo "stacktests: the repo root holds an instance that is not the rig." >&2 + echo " Refusing to overwrite it with the stash." >&2; exit 1 + fi + + running=$(cat "$STASH_DIR/instance/running") + mv "$STASH_DIR/instance/stack.toml" "$STASH_DIR/instance/users.toml" "$STASH_DIR/instance/.stack" "$REPO_ROOT/" + rm "$STASH_DIR/instance/running" + rmdir "$STASH_DIR/instance" + echo "==> demo instance restored" + if [[ -n "$running" ]]; then + echo "==> stack up $running" + stack_cli up "$running" + fi +} + +# e2e and smoke need the rig at the repo root. When the demo is there +# instead, swap it out for the run and back afterwards, whatever the +# run's outcome. pytest then runs as a child rather than through exec, +# since exec would replace this shell and the trap with it. +use_rig() { + if [[ -f "$REPO_ROOT/.stack/.test-instance" || ! -f "$REPO_ROOT/stack.toml" ]]; then + return + fi + stash_instance + trap 'rc=$?; trap - EXIT; unstash_instance; exit $rc' EXIT + trap 'exit 130' INT TERM +} + # Bootstrap the repo root as a test instance and seed global secrets. # Fails loudly if the repo root already holds a non-test stack.toml — # see _seed_secrets.py's TestInstanceConflict message for the cleanup @@ -155,74 +260,11 @@ case "${1:-}" in ;; stash) - # Set the dev instance at the repo root aside so e2e can use the root. - # - # The rig and a dev instance share only the root config (stack.toml, - # users.toml, .stack/) and the container names. Their data dirs are - # separate (~/famstack-test-data vs the instance's own), so moving - # the three config entries and stopping the containers is the whole - # swap: no data is copied. The rig's config comes back from its own - # slot, so its secrets still match the test data it left behind. - if [[ -f "$REPO_ROOT/.stack/.test-instance" ]]; then - echo "stacktests: the repo root is already the test instance." >&2; exit 1 - fi - if [[ ! -f "$REPO_ROOT/stack.toml" ]]; then - echo "stacktests: no instance at the repo root to stash." >&2; exit 1 - fi - if [[ -e "$STASH_DIR/instance" ]]; then - echo "stacktests: $STASH_DIR/instance already holds a stash." >&2 - echo " Run 'stacktests unstash' first." >&2; exit 1 - fi - - running=$(running_stacklets) - if [[ -n "$running" ]]; then - echo "==> stack down $running" - stack_cli down "$running" - fi - - mkdir -p "$STASH_DIR/instance" - echo "$running" > "$STASH_DIR/instance/running" - mv "$REPO_ROOT/stack.toml" "$REPO_ROOT/users.toml" "$REPO_ROOT/.stack" "$STASH_DIR/instance/" - echo "==> dev instance stashed in $STASH_DIR/instance" - - if [[ -d "$STASH_DIR/rig" ]]; then - mv "$STASH_DIR/rig/stack.toml" "$STASH_DIR/rig/users.toml" "$STASH_DIR/rig/.stack" "$REPO_ROOT/" - rmdir "$STASH_DIR/rig" - echo "==> test rig config restored from $STASH_DIR/rig" - fi - echo "✓ Repo root is free for the rig. 'stacktests unstash' brings the instance back." + stash_instance ;; unstash) - # Reverse of stash: park the rig's config in its slot, put the dev - # instance back and start what was running when it was stashed. - if [[ ! -d "$STASH_DIR/instance" ]]; then - echo "stacktests: nothing stashed in $STASH_DIR/instance." >&2; exit 1 - fi - - if [[ -f "$REPO_ROOT/.stack/.test-instance" ]]; then - running=$(running_stacklets) - if [[ -n "$running" ]]; then - echo "==> stack down $running (test rig)" - stack_cli down "$running" - fi - mkdir -p "$STASH_DIR/rig" - mv "$REPO_ROOT/stack.toml" "$REPO_ROOT/users.toml" "$REPO_ROOT/.stack" "$STASH_DIR/rig/" - echo "==> test rig config kept in $STASH_DIR/rig" - elif [[ -e "$REPO_ROOT/stack.toml" || -e "$REPO_ROOT/.stack" ]]; then - echo "stacktests: the repo root holds an instance that is not the rig." >&2 - echo " Refusing to overwrite it with the stash." >&2; exit 1 - fi - - running=$(cat "$STASH_DIR/instance/running") - mv "$STASH_DIR/instance/stack.toml" "$STASH_DIR/instance/users.toml" "$STASH_DIR/instance/.stack" "$REPO_ROOT/" - rm "$STASH_DIR/instance/running" - rmdir "$STASH_DIR/instance" - echo "==> dev instance restored" - if [[ -n "$running" ]]; then - echo "==> stack up $running" - stack_cli up "$running" - fi + unstash_instance ;; pytest) @@ -244,22 +286,24 @@ case "${1:-}" in # Seeds secrets + brings up required stacklets first. Both steps # are idempotent, so repeat runs pay near-zero cost. shift || true + use_rig seed_secrets for sid in "${REQUIRED_STACKLETS[@]}"; do stack_cli up "$sid" > /dev/null done - exec uv run --extra test pytest tests/integration/ -k "_e2e" -m "not demo_rig" --durations=20 "$@" + uv run --extra test pytest tests/integration/ -k "_e2e" -m "not demo_rig" --durations=20 "$@" ;; smoke) # Run the smoke subset of end-to-end tests. Like e2e, this seeds # secrets and ensures required stacklets are up before pytest starts. shift || true + use_rig seed_secrets for sid in "${REQUIRED_STACKLETS[@]}"; do stack_cli up "$sid" > /dev/null done - exec uv run --extra test pytest tests/integration/ -k "_e2e" -m smoke "$@" + uv run --extra test pytest tests/integration/ -k "_e2e" -m smoke "$@" ;; demo-rig|demo) @@ -450,9 +494,12 @@ Autonomy — what an agent may run unattended: check nothing else is mid-run before starting. up, down, e2e, smoke, demo-rig, eval, stash, unstash - e2e needs the root to be the test instance. On a dev - instance, `stash` first and `unstash` after: the swap - moves config only and leaves both data dirs alone. + e2e and smoke need the root to be the test instance. On + the Simpsons demo they stash it for the run and unstash + it afterwards, pass or fail; `stash`/`unstash` do the same + by hand, to keep the rig up for a look after a failure. + Any other instance is refused. The swap moves config only + and leaves both data dirs alone. destructive Wipes state that re-running cannot recover. reset, cleanup, down ai From 6ef399d54e62d2d762b2e1e5ca3e06d892428597 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 08:40:10 +0200 Subject: [PATCH 3/8] docs: say e2e runs from the Simpsons demo without uninstalling it The lane table told people to uninstall the instance before `make test-e2e`. It now stashes the Simpsons demo for the run and restores it afterwards, and refuses any other instance. The e2e time is unknown again: the old 20-30 minutes included a Synapse invite limit that no longer applies, and no full run has been timed since. --- Makefile | 4 ++-- tests/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index b17d7eba..5f186775 100644 --- a/Makefile +++ b/Makefile @@ -24,8 +24,8 @@ help: " make test-unit ~75s nothing no before a push, or when a piece of work is done" \ " make test-lifecycle ~6m Docker yes lifecycle, env, compose, ports, health" \ " make test-demo ~4m demo instance yes it must work against the running bots" \ - " make test-smoke ? test-owned rig yes quick cross-service check" \ - " make test-e2e 20-30m uninstalled yes end of a branch, before review" \ + " make test-smoke ? rig or demo yes quick cross-service check" \ + " make test-e2e ? rig or demo yes end of a branch, before review" \ "" \ " make typecheck types across the shipped code, not a gate" \ " make hooks point git at the repo's hooks" \ diff --git a/tests/README.md b/tests/README.md index 2c8fc02a..9c4ed5f0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -37,8 +37,8 @@ minute and a quarter would only teach people to skip it. | `make test-unit` | ~75s | nothing | no | Before a push, or when a coherent piece of work is done. Offline framework and stacklet tests: no Docker, no live services, no production data. | | `make test-lifecycle` | ~6m | Docker | yes | You changed lifecycle, config rendering, `.env`, compose, container names, ports, volumes or health wiring. Owns a throwaway instance on fixed names. | | `make test-demo` | ~4m | the demo instance running | yes | The behaviour has to work against the already-running bots and real service wiring. Tests create unique data and clean up after themselves; they never reset the instance. | -| `make test-smoke` | ? | a test-owned rig | yes | A quick answer on a cross-service path. Seeds secrets and brings the required stacklets up first, so it is not read-only. | -| `make test-e2e` | 20-30m | an uninstalled instance | yes | End of a branch, or before asking for review, when the change crosses container boundaries. Uninstall first: it seeds and owns the instance it runs against. | +| `make test-smoke` | ? | the test rig or the Simpsons demo | yes | A quick answer on a cross-service path. Seeds secrets and brings the required stacklets up first, so it is not read-only. | +| `make test-e2e` | ? | the test rig or the Simpsons demo | yes | End of a branch, or before asking for review, when the change crosses container boundaries. On the demo it stashes the instance for the run and brings it back afterwards, pass or fail; any other instance is refused. | **Exclusive** means the lane owns fixed container names and ports, so exactly one run at a time on this Mac. Check nothing else is mid-run before starting From 49415f21a061e6c6b54c16f67981fe64afa7761c Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 08:52:51 +0200 Subject: [PATCH 4/8] build: adopt the scripts-to-rule-them-all layout One entry point per job under `script/`, as GitHub's convention has it: - `script/setup` sets a clone up; it is `tools/init-repo`, moved - `script/update` re-runs setup and restarts stale stacklets - `script/test [lane]` runs a lane; `script/test help` prints the table - `script/cibuild` is everything CI checks, runnable on a laptop - `script/server` starts every installed stacklet The Makefile targets stay as one-line aliases. CI calls `script/cibuild` and nothing else, so local and CI runs check the same things: before, commit-lint, the lock check and the version check only ran in CI. --- .../famstack-lsp/hooks/session-start.sh | 2 +- .github/workflows/ci.yml | 67 ++----------- CONTRIBUTING.md | 8 +- Makefile | 78 +++------------ docs/agent/dev.md | 2 +- script/cibuild | 66 +++++++++++++ script/server | 10 ++ tools/init-repo => script/setup | 12 +-- script/test | 95 +++++++++++++++++++ script/update | 17 ++++ 10 files changed, 222 insertions(+), 135 deletions(-) create mode 100755 script/cibuild create mode 100755 script/server rename tools/init-repo => script/setup (91%) create mode 100755 script/test create mode 100755 script/update diff --git a/.claude/skills/famstack-lsp/hooks/session-start.sh b/.claude/skills/famstack-lsp/hooks/session-start.sh index 6955d167..7d8e76ae 100755 --- a/.claude/skills/famstack-lsp/hooks/session-start.sh +++ b/.claude/skills/famstack-lsp/hooks/session-start.sh @@ -7,7 +7,7 @@ # server answers from a partial index instead of waiting. if ! command -v basedpyright-langserver >/dev/null 2>&1; then - echo "famstack-lsp: basedpyright-langserver is not on PATH, so there is no language server this session. Install it with 'uv tool install basedpyright' (or run tools/init-repo), then /reload-plugins. Until then, use 'uvx basedpyright ' for diagnostics and grep for navigation." + echo "famstack-lsp: basedpyright-langserver is not on PATH, so there is no language server this session. Install it with 'uv tool install basedpyright' (or run script/setup), then /reload-plugins. Until then, use 'uvx basedpyright ' for diagnostics and grep for navigation." exit 0 fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28d4588d..eccd728b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,7 @@ # Tier 1 of the release gate (docs/agent/dev.md, "Releases"): the checks -# that need no Docker and no macOS, including that every commit subject -# parses as a changelog entry. Runs on every push/PR; on version -# tags it additionally enforces that the tag, the CLI VERSION, and -# pyproject.toml all agree. Tiers 2 (integration suite) and 3 (fresh +# that need no Docker and no macOS, run by script/cibuild. Runs on every +# push/PR; on version tags it additionally enforces that the tag, the CLI +# VERSION, and pyproject.toml all agree. Tiers 2 (integration suite) and 3 (fresh # install, ai stacklet) stay manual — see docs/cleanup-backlog.md. name: CI @@ -21,64 +20,12 @@ jobs: - uses: astral-sh/setup-uv@v5 - - name: Lint (ruff) - run: uvx ruff check . - - - name: Lockfile in sync with pyproject.toml - run: uv lock --check - - - name: Version consistency + # Everything the job checks lives in script/cibuild, so it runs + # the same on a laptop. The PR values are empty on a push. + - name: script/cibuild env: GITHUB_REF: ${{ github.ref }} - run: | - uv run python - <<'EOF' - import os, re, sys, tomllib - - def normalize(v: str) -> str: - """PEP-440-ish: v0.3.0-beta.1 / 0.3.0b1 -> 0.3.0b1""" - v = v.strip().lstrip("v").lower() - v = re.sub(r"[-.]?beta[-.]?", "b", v) - v = re.sub(r"[-.]?alpha[-.]?", "a", v) - v = re.sub(r"[-.]?rc[-.]?", "rc", v) - return v - - cli = re.search( - r'^VERSION = "([^"]+)"', - open("lib/stack/cli.py").read(), re.M, - ).group(1) - pyproject = tomllib.load(open("pyproject.toml", "rb"))["project"]["version"] - - versions = {"lib/stack/cli.py": cli, "pyproject.toml": pyproject} - ref = os.environ.get("GITHUB_REF", "") - if ref.startswith("refs/tags/v"): - versions["tag"] = ref.removeprefix("refs/tags/") - - normalized = {src: normalize(v) for src, v in versions.items()} - if len(set(normalized.values())) != 1: - print("Version mismatch:") - for src, v in versions.items(): - print(f" {src}: {v} (normalized: {normalized[src]})") - sys.exit(1) - print("Versions agree:", ", ".join(f"{s}={v}" for s, v in versions.items())) - EOF - - - name: Commit convention - # The changelog is generated from these subjects, so one that - # does not parse is an entry missing from the release notes. - # Squash merging makes the PR title the subject that lands, so - # it is checked too. - if: github.event_name == 'pull_request' - env: PR_TITLE: ${{ github.event.pull_request.title }} BASE: ${{ github.event.pull_request.base.sha }} HEAD: ${{ github.event.pull_request.head.sha }} - run: | - git fetch --no-tags --depth=50 origin "$BASE" "$HEAD" 2>/dev/null || true - python3 tools/commit-lint --title "$PR_TITLE" - python3 tools/commit-lint --range "$BASE..$HEAD" - - - name: Framework tests - run: uv run --extra test pytest tests/framework/ --ignore=tests/framework/test_config_to_container.py -q - - - name: Stacklet tests - run: uv run --extra test pytest tests/stacklets/ -q + run: script/cibuild diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62948270..7e1f54db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,10 +10,10 @@ One entry point, [Homebrew](https://brew.sh), which brings the rest: ```bash brew install uv -tools/init-repo +script/setup ``` -`tools/init-repo` is the one-time setup for a clone, and is safe to re-run. It checks the two things that otherwise fail confusingly hours later (an old Python, a missing `uv`), creates the test virtualenv, points git at the repo's hooks, and installs the language server binary. Everything it does is local to your clone. +`script/setup` is the one-time setup for a clone, and is safe to re-run. It checks the two things that otherwise fail confusingly hours later (an old Python, a missing `uv`), creates the test virtualenv, points git at the repo's hooks, and installs the language server binary. Everything it does is local to your clone. The hooks are the part git cannot do for you: a fresh clone must not be able to run code, so no repo can enable its own hooks on clone. `make test-unit` and `make typecheck` also enable them, so in practice they are on before your first commit either way. @@ -35,7 +35,7 @@ Neither is a merge gate, and the type checker is not clean today. Run it on what ## Language server -Agents working in this repo get a language server, so they can ask for a definition or every reference to a symbol instead of grepping for a name and hoping it is unique. `tools/init-repo` installs the binary; by hand it is: +Agents working in this repo get a language server, so they can ask for a definition or every reference to a symbol instead of grepping for a name and hoping it is unique. `script/setup` installs the binary; by hand it is: ```bash uv tool install basedpyright @@ -75,7 +75,7 @@ A bare `make typecheck` reports on `lib`, `stacklets`, `tools` and `hooks`. `tes All three live in [docs/agent/dev.md](docs/agent/dev.md), which is the canonical reference and stays shorter than a duplicate here would. The short version: the hooks lint every commit for you, `make test-unit` before a push or when a piece of work is done, module tests over unit tests, feature branches only, never push without asking. Commit subjects are the changelog famstack.dev renders, so they follow `(): ` and every one has to parse. -The hooks check ruff on staged files and the commit subject before a commit exists, rather than in CI. `tools/init-repo` turns them on, as do `make test-unit` and `make typecheck`. To do only that: +The hooks check ruff on staged files and the commit subject before a commit exists, rather than in CI. `script/setup` turns them on, as do `make test-unit` and `make typecheck`. To do only that: ```bash make hooks # or: git config core.hooksPath hooks diff --git a/Makefile b/Makefile index 5f186775..ba087ad9 100644 --- a/Makefile +++ b/Makefile @@ -1,81 +1,33 @@ -# famstack development tasks. -# -# One name per lane. The aliases that used to exist (unit, fast, test, -# demo, lifecycle, e2e, smoke, test-all, test-integration) are gone: they -# were eighteen names for six jobs, and `test-all` ran only the lifecycle -# lane, which is worse than not existing. -# -# `make` on its own prints the table. - -PYTEST = uv run --extra test pytest +# Aliases for the scripts in script/, which hold the logic. `make` on +# its own prints the lane table. Details: script/test help. .DEFAULT_GOAL := help help: - @printf '%s\n' \ - "" \ - " Linting is automatic: the hooks run ruff on staged files and check" \ - " the commit subject. Nothing to remember per commit." \ - "" \ - " Run the cheapest lane that proves what you changed." \ - "" \ - " LANE TIME NEEDS EXCLUSIVE RUN IT WHEN" \ - " make lint <1s nothing no the whole tree, when you want it" \ - " make test-unit ~75s nothing no before a push, or when a piece of work is done" \ - " make test-lifecycle ~6m Docker yes lifecycle, env, compose, ports, health" \ - " make test-demo ~4m demo instance yes it must work against the running bots" \ - " make test-smoke ? rig or demo yes quick cross-service check" \ - " make test-e2e ? rig or demo yes end of a branch, before review" \ - "" \ - " make typecheck types across the shipped code, not a gate" \ - " make hooks point git at the repo's hooks" \ - "" \ - " Exclusive means it owns fixed container names and ports, so exactly" \ - " one run at a time on this Mac. ? means nobody has timed it yet;" \ - " if you run one, write the number down. Details: tests/README.md" \ - "" + @script/test help -# The whole tree, the way CI runs it. Per commit the hook already covers -# the staged files, which is faster and is why nothing here is per commit. -lint: hooks - uvx ruff check . +hooks: + @script/setup --hooks-only -# Type check the shipped code. Not a gate: read what it says about the files -# you touched. `uvx basedpyright ` narrows it further. -typecheck: hooks - -uvx basedpyright +lint: + @script/test lint -# Point git at the repo's own hooks. Git will not do this on clone, by -# design: a fresh clone must not be able to run code. The nearest honest -# thing is to do it the first time someone uses the repo's tooling, which -# is what the lanes below depend on. -# -# Only when unset, so an explicit choice (an absolute path, a different -# directory, deliberately no hooks) is never overwritten. -hooks: - @tools/init-repo --hooks-only +typecheck: + @script/test typecheck -# Offline. No Docker, no live services, nothing shared. Fast enough to run -# when a piece of work is finished, too slow to run on every commit. -test-unit: hooks - $(PYTEST) tests/framework tests/stacklets -v --ignore=tests/framework/test_config_to_container.py +test-unit: + @script/test unit -# Docker lifecycle: up, down, destroy, env rendering, container environment. -# Owns its own throwaway instance, but on fixed names and ports. test-lifecycle: - $(PYTEST) tests/framework/test_config_to_container.py -v + @script/test lifecycle -# Against the demo instance already running in this checkout. Tests create -# unique data and clean up after themselves; they never reset the instance. test-demo: - tests/integration/stacktests demo-rig + @script/test demo -# A small subset of the managed-rig e2e lane, for a quick answer. test-smoke: - tests/integration/stacktests smoke + @script/test smoke -# The full managed rig: seeds a test-owned instance and runs every e2e test. test-e2e: - tests/integration/stacktests e2e + @script/test e2e .PHONY: help hooks lint typecheck test-unit test-lifecycle test-demo test-smoke test-e2e diff --git a/docs/agent/dev.md b/docs/agent/dev.md index 8544eced..0b60be23 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -390,7 +390,7 @@ subject is not: it appears in plain-text contexts too, including `tools/commit-lint` enforces the header, and is the same check in every place that matters: the `commit-msg` hook rejects it before the commit exists, CI rejects the PR title and every commit in the PR, and the -release gate runs it over the range being tagged. `tools/init-repo` +release gate runs it over the range being tagged. `script/setup` turns the hooks on, as do `make test-unit` and `make typecheck`; git cannot do it on clone, by design. diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 00000000..419668bf --- /dev/null +++ b/script/cibuild @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# script/cibuild — everything CI checks, runnable on a laptop. +# +# Tier 1 of the release gate (docs/agent/dev.md, "Releases"): the checks +# that need no Docker and no macOS. The workflow calls this and nothing +# else, so a green run here means what a green run there means. +# +# Environment the workflow passes through, all optional locally: +# GITHUB_REF on a version tag, the tag must match the versions +# PR_TITLE BASE HEAD on a pull request, the title and every commit +# subject in BASE..HEAD must parse as a changelog entry + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> lint" +uvx ruff check . + +echo "==> lockfile in sync with pyproject.toml" +uv lock --check + +echo "==> version consistency" +uv run python - <<'EOF' +import os, re, sys, tomllib + +def normalize(v: str) -> str: + """PEP-440-ish: v0.3.0-beta.1 / 0.3.0b1 -> 0.3.0b1""" + v = v.strip().lstrip("v").lower() + v = re.sub(r"[-.]?beta[-.]?", "b", v) + v = re.sub(r"[-.]?alpha[-.]?", "a", v) + v = re.sub(r"[-.]?rc[-.]?", "rc", v) + return v + +cli = re.search( + r'^VERSION = "([^"]+)"', + open("lib/stack/cli.py").read(), re.M, +).group(1) +pyproject = tomllib.load(open("pyproject.toml", "rb"))["project"]["version"] + +versions = {"lib/stack/cli.py": cli, "pyproject.toml": pyproject} +ref = os.environ.get("GITHUB_REF", "") +if ref.startswith("refs/tags/v"): + versions["tag"] = ref.removeprefix("refs/tags/") + +normalized = {src: normalize(v) for src, v in versions.items()} +if len(set(normalized.values())) != 1: + print("Version mismatch:") + for src, v in versions.items(): + print(f" {src}: {v} (normalized: {normalized[src]})") + sys.exit(1) +print("Versions agree:", ", ".join(f"{s}={v}" for s, v in versions.items())) +EOF + +# The changelog is generated from these subjects, so one that does not +# parse is an entry missing from the release notes. The PR title is +# checked too, since a squash merge makes it the subject that lands. +if [[ -n "${PR_TITLE:-}" ]]; then + echo "==> commit convention" + git fetch --no-tags --depth=50 origin "$BASE" "$HEAD" 2>/dev/null || true + python3 tools/commit-lint --title "$PR_TITLE" + python3 tools/commit-lint --range "$BASE..$HEAD" +fi + +echo "==> unit tests" +script/test unit -q diff --git a/script/server b/script/server new file mode 100755 index 00000000..229a4733 --- /dev/null +++ b/script/server @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# script/server — start every installed stacklet of the instance here. +# +# First-time install is `./stack`; this only brings back what exists. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +exec ./stack up all diff --git a/tools/init-repo b/script/setup similarity index 91% rename from tools/init-repo rename to script/setup index 9fb591a2..9a1a47a0 100755 --- a/tools/init-repo +++ b/script/setup @@ -5,8 +5,8 @@ Everything here is one-time, idempotent and local to the clone: the test virtualenv, the git hooks, and the checks that catch the two things that otherwise fail confusingly hours later, an old Python and a missing uv. - tools/init-repo the lot, with a report - tools/init-repo --hooks-only just the hooks, silent if already set + script/setup the lot, with a report + script/setup --hooks-only just the hooks, silent if already set Deliberately 3.9-compatible so that on Apple's system Python it reports the version problem instead of failing to parse. @@ -122,7 +122,7 @@ def main() -> int: say("\n Setting up this clone\n") ready = check_python() and check_uv() if not ready: - say("\n Fix the above and run tools/init-repo again.\n") + say("\n Fix the above and run script/setup again.\n") return 1 ready = sync_venv() @@ -130,9 +130,9 @@ def main() -> int: install_language_server() say("\n Next:") - say(" make the test lanes, what each costs, when to run it") - say(" make test-unit the fast suite, before a push") - say(" make typecheck types across the shipped code") + say(" script/test help the test lanes, what each costs, when to run it") + say(" script/test the fast suite, before a push") + say(" script/test typecheck types across the shipped code") say(" docs/agent/dev.md how to write code here\n") return 0 if ready else 1 diff --git a/script/test b/script/test new file mode 100755 index 00000000..30c57b85 --- /dev/null +++ b/script/test @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# script/test — run one test lane. +# +# script/test [lane] [pytest args] lane defaults to unit +# script/test help the lanes, what each costs, when to run it +# +# One name per lane. `make ` calls this, so the two never disagree. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +PYTEST=(uv run --extra test pytest) + +lanes() { + cat <<'EOF' + + Linting is automatic: the hooks run ruff on staged files and check + the commit subject. Nothing to remember per commit. + + Run the cheapest lane that proves what you changed. + + LANE TIME NEEDS EXCLUSIVE RUN IT WHEN + script/test lint <1s nothing no the whole tree, when you want it + script/test unit ~75s nothing no before a push, or when a piece of work is done + script/test lifecycle ~6m Docker yes lifecycle, env, compose, ports, health + script/test demo ~4m demo instance yes it must work against the running bots + script/test smoke ? rig or demo yes quick cross-service check + script/test e2e ? rig or demo yes end of a branch, before review + + script/test typecheck types across the shipped code, not a gate + + Exclusive means it owns fixed container names and ports, so exactly + one run at a time on this Mac. ? means nobody has timed it yet; + if you run one, write the number down. Details: tests/README.md + +EOF +} + +lane="${1:-unit}" +shift || true + +case "$lane" in + help|-h|--help) + lanes + ;; + + # The whole tree, the way CI runs it. Per commit the hook already covers + # the staged files, which is faster and is why nothing here is per commit. + lint) + script/setup --hooks-only + uvx ruff check . "$@" + ;; + + # Not a gate: read what it says about the files you touched. + # `uvx basedpyright ` narrows it further. + typecheck) + script/setup --hooks-only + uvx basedpyright "$@" || true + ;; + + # Offline. No Docker, no live services, nothing shared. Fast enough to run + # when a piece of work is finished, too slow to run on every commit. + unit) + script/setup --hooks-only + "${PYTEST[@]}" tests/framework tests/stacklets -v \ + --ignore=tests/framework/test_config_to_container.py "$@" + ;; + + # Docker lifecycle: up, down, destroy, env rendering, container environment. + # Owns its own throwaway instance, but on fixed names and ports. + lifecycle) + "${PYTEST[@]}" tests/framework/test_config_to_container.py -v "$@" + ;; + + # Against the demo instance already running in this checkout. Tests create + # unique data and clean up after themselves; they never reset the instance. + demo) + tests/integration/stacktests demo-rig "$@" + ;; + + # The managed rig. On the Simpsons demo, stacktests stashes it for the run. + smoke) + tests/integration/stacktests smoke "$@" + ;; + e2e) + tests/integration/stacktests e2e "$@" + ;; + + *) + echo "script/test: unknown lane '$lane'" >&2 + lanes >&2 + exit 1 + ;; +esac diff --git a/script/update b/script/update new file mode 100755 index 00000000..0e064ffd --- /dev/null +++ b/script/update @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# script/update — bring this clone up to date after a pull or a branch switch. +# +# Re-runs setup, which syncs the virtualenv with the lockfile and is +# otherwise a no-op, then restarts the stacklets whose running code the +# checkout has moved past. A clone with no instance stops after setup. +# Moving to a release is `./stack update`, not this. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +script/setup + +if [[ -f stack.toml ]]; then + ./stack restart +fi From d8ff8859dea52f5c6ccb67574dabb7dca5351500 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 08:58:37 +0200 Subject: [PATCH 5/8] test: move the backup engine tests to the lifecycle lane Each backup engine test creates and mounts a 100 MB APFS disk image with `hdiutil`, about 1.2 seconds apiece: 20 of the unit lane's 67 seconds. They drive the host rather than Python, like the Docker lifecycle tests, so they now run in that lane. The unit lane takes about 47 seconds. --- docs/agent/dev.md | 5 +++-- script/test | 22 ++++++++++++++++------ tests/README.md | 9 ++++++--- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/agent/dev.md b/docs/agent/dev.md index 0b60be23..59c9b4bc 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -211,9 +211,10 @@ Test runner: **`uv run --extra test pytest`**. The `test` extra in `pyproject.to ```bash make # the lane table: cost, what each needs, when to run it make lint # <1s, the whole tree. The hook already covers staged files. -make test-unit # ~75s, offline, no Docker. Before a push, or when a +make test-unit # ~50s, offline, no Docker. Before a push, or when a # piece of work is done. Not per commit. -make test-lifecycle # ~6m, Docker. Lifecycle, env rendering, compose, health. +make test-lifecycle # ~6m, Docker, APFS. Lifecycle, env rendering, compose, + # health, and the backup engine on real disk images. make test-demo # against the running demo instance. make test-smoke # quick managed-rig e2e subset. make test-e2e # full managed-rig e2e suite. diff --git a/script/test b/script/test index 30c57b85..d7424c07 100755 --- a/script/test +++ b/script/test @@ -12,6 +12,14 @@ cd "$(dirname "$0")/.." PYTEST=(uv run --extra test pytest) +# Kept out of the unit lane because they drive the host, not Python: +# Docker for the one, `hdiutil` mounting an APFS image per test for the +# other, which alone was a third of the unit lane's time. +SLOW_FILES=( + tests/framework/test_config_to_container.py + tests/stacklets/test_backup_e2e.py +) + lanes() { cat <<'EOF' @@ -22,8 +30,8 @@ lanes() { LANE TIME NEEDS EXCLUSIVE RUN IT WHEN script/test lint <1s nothing no the whole tree, when you want it - script/test unit ~75s nothing no before a push, or when a piece of work is done - script/test lifecycle ~6m Docker yes lifecycle, env, compose, ports, health + script/test unit ~50s nothing no before a push, or when a piece of work is done + script/test lifecycle ~6m Docker, APFS yes lifecycle, env, compose, ports, health, backup script/test demo ~4m demo instance yes it must work against the running bots script/test smoke ? rig or demo yes quick cross-service check script/test e2e ? rig or demo yes end of a branch, before review @@ -64,13 +72,15 @@ case "$lane" in unit) script/setup --hooks-only "${PYTEST[@]}" tests/framework tests/stacklets -v \ - --ignore=tests/framework/test_config_to_container.py "$@" + "${SLOW_FILES[@]/#/--ignore=}" "$@" ;; - # Docker lifecycle: up, down, destroy, env rendering, container environment. - # Owns its own throwaway instance, but on fixed names and ports. + # What the host itself provides: the Docker lifecycle (up, down, destroy, + # env rendering, container environment) on its own throwaway instance, + # with fixed names and ports, and the backup engine against real APFS + # disk images, which it creates and mounts one per test. lifecycle) - "${PYTEST[@]}" tests/framework/test_config_to_container.py -v "$@" + "${PYTEST[@]}" "${SLOW_FILES[@]}" -v "$@" ;; # Against the demo instance already running in this checkout. Tests create diff --git a/tests/README.md b/tests/README.md index 9c4ed5f0..d37d1768 100644 --- a/tests/README.md +++ b/tests/README.md @@ -34,8 +34,8 @@ minute and a quarter would only teach people to skip it. | Lane | Time | Needs | Exclusive | Run it when | |---|---|---|---|---| | `make lint` | <1s | nothing | no | The whole tree, when you want more than the staged files the hook checks. What CI runs. | -| `make test-unit` | ~75s | nothing | no | Before a push, or when a coherent piece of work is done. Offline framework and stacklet tests: no Docker, no live services, no production data. | -| `make test-lifecycle` | ~6m | Docker | yes | You changed lifecycle, config rendering, `.env`, compose, container names, ports, volumes or health wiring. Owns a throwaway instance on fixed names. | +| `make test-unit` | ~50s | nothing | no | Before a push, or when a coherent piece of work is done. Offline framework and stacklet tests: no Docker, no live services, no production data. | +| `make test-lifecycle` | ~6m | Docker, APFS | yes | You changed lifecycle, config rendering, `.env`, compose, container names, ports, volumes, health wiring or the backup engine. Owns a throwaway instance on fixed names, and mounts an APFS disk image per backup test. | | `make test-demo` | ~4m | the demo instance running | yes | The behaviour has to work against the already-running bots and real service wiring. Tests create unique data and clean up after themselves; they never reset the instance. | | `make test-smoke` | ? | the test rig or the Simpsons demo | yes | A quick answer on a cross-service path. Seeds secrets and brings the required stacklets up first, so it is not read-only. | | `make test-e2e` | ? | the test rig or the Simpsons demo | yes | End of a branch, or before asking for review, when the change crosses container boundaries. On the demo it stashes the instance for the run and brings it back afterwards, pass or fail; any other instance is refused. | @@ -57,7 +57,10 @@ destructive test-rig commands still require care. `tests/framework/` contains framework-level tests. Most are offline unit tests. `tests/framework/test_config_to_container.py` is intentionally separate because -it talks to Docker and dominates runtime. +it talks to Docker and dominates runtime. `tests/stacklets/test_backup_e2e.py` +joins it in the lifecycle lane: it creates and mounts an APFS disk image per +test, about a second each, which was a third of the unit lane. The list lives +in `script/test` as `SLOW_FILES`. `tests/stacklets/` contains stacklet-level unit tests. These should exercise stacklet modules through public boundaries with local fixtures, fake HTTP From 5e420792586fb5f15462cb3c8ac6398acc149c9e Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 09:00:02 +0200 Subject: [PATCH 6/8] test: rename the lifecycle lane to integration The lane now runs the backup engine against real APFS disk images as well as the Docker lifecycle tests, so "lifecycle" no longer describes it. `make test-integration` and `script/test integration` are the lane for the heavier profiles that run real things on the host; `make test-lifecycle` is gone. --- Makefile | 6 ++--- docs/agent/dev.md | 2 +- script/test | 26 ++++++++++---------- tests/README.md | 4 +-- tests/framework/fixtures/guest/stacklet.toml | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index ba087ad9..0487b551 100644 --- a/Makefile +++ b/Makefile @@ -18,8 +18,8 @@ typecheck: test-unit: @script/test unit -test-lifecycle: - @script/test lifecycle +test-integration: + @script/test integration test-demo: @script/test demo @@ -30,4 +30,4 @@ test-smoke: test-e2e: @script/test e2e -.PHONY: help hooks lint typecheck test-unit test-lifecycle test-demo test-smoke test-e2e +.PHONY: help hooks lint typecheck test-unit test-integration test-demo test-smoke test-e2e diff --git a/docs/agent/dev.md b/docs/agent/dev.md index 59c9b4bc..c9a77ea6 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -213,7 +213,7 @@ make # the lane table: cost, what each needs, when to run it make lint # <1s, the whole tree. The hook already covers staged files. make test-unit # ~50s, offline, no Docker. Before a push, or when a # piece of work is done. Not per commit. -make test-lifecycle # ~6m, Docker, APFS. Lifecycle, env rendering, compose, +make test-integration # ~6m, Docker, APFS. Lifecycle, env rendering, compose, # health, and the backup engine on real disk images. make test-demo # against the running demo instance. make test-smoke # quick managed-rig e2e subset. diff --git a/script/test b/script/test index d7424c07..5e5a2098 100755 --- a/script/test +++ b/script/test @@ -28,15 +28,15 @@ lanes() { Run the cheapest lane that proves what you changed. - LANE TIME NEEDS EXCLUSIVE RUN IT WHEN - script/test lint <1s nothing no the whole tree, when you want it - script/test unit ~50s nothing no before a push, or when a piece of work is done - script/test lifecycle ~6m Docker, APFS yes lifecycle, env, compose, ports, health, backup - script/test demo ~4m demo instance yes it must work against the running bots - script/test smoke ? rig or demo yes quick cross-service check - script/test e2e ? rig or demo yes end of a branch, before review + LANE TIME NEEDS EXCLUSIVE RUN IT WHEN + script/test lint <1s nothing no the whole tree, when you want it + script/test unit ~50s nothing no before a push, or when a piece of work is done + script/test integration ~6m Docker, APFS yes lifecycle, env, compose, ports, health, backup + script/test demo ~4m demo instance yes it must work against the running bots + script/test smoke ? rig or demo yes quick cross-service check + script/test e2e ? rig or demo yes end of a branch, before review - script/test typecheck types across the shipped code, not a gate + script/test typecheck types across the shipped code, not a gate Exclusive means it owns fixed container names and ports, so exactly one run at a time on this Mac. ? means nobody has timed it yet; @@ -75,11 +75,11 @@ case "$lane" in "${SLOW_FILES[@]/#/--ignore=}" "$@" ;; - # What the host itself provides: the Docker lifecycle (up, down, destroy, - # env rendering, container environment) on its own throwaway instance, - # with fixed names and ports, and the backup engine against real APFS - # disk images, which it creates and mounts one per test. - lifecycle) + # The heavier profiles, running real things on the host: the Docker + # lifecycle (up, down, destroy, env rendering, container environment) + # on its own throwaway instance with fixed names and ports, and the + # backup engine against real APFS disk images, one mounted per test. + integration) "${PYTEST[@]}" "${SLOW_FILES[@]}" -v "$@" ;; diff --git a/tests/README.md b/tests/README.md index d37d1768..d494ba79 100644 --- a/tests/README.md +++ b/tests/README.md @@ -35,7 +35,7 @@ minute and a quarter would only teach people to skip it. |---|---|---|---|---| | `make lint` | <1s | nothing | no | The whole tree, when you want more than the staged files the hook checks. What CI runs. | | `make test-unit` | ~50s | nothing | no | Before a push, or when a coherent piece of work is done. Offline framework and stacklet tests: no Docker, no live services, no production data. | -| `make test-lifecycle` | ~6m | Docker, APFS | yes | You changed lifecycle, config rendering, `.env`, compose, container names, ports, volumes, health wiring or the backup engine. Owns a throwaway instance on fixed names, and mounts an APFS disk image per backup test. | +| `make test-integration` | ~6m | Docker, APFS | yes | You changed lifecycle, config rendering, `.env`, compose, container names, ports, volumes, health wiring or the backup engine. Owns a throwaway instance on fixed names, and mounts an APFS disk image per backup test. | | `make test-demo` | ~4m | the demo instance running | yes | The behaviour has to work against the already-running bots and real service wiring. Tests create unique data and clean up after themselves; they never reset the instance. | | `make test-smoke` | ? | the test rig or the Simpsons demo | yes | A quick answer on a cross-service path. Seeds secrets and brings the required stacklets up first, so it is not read-only. | | `make test-e2e` | ? | the test rig or the Simpsons demo | yes | End of a branch, or before asking for review, when the change crosses container boundaries. On the demo it stashes the instance for the run and brings it back afterwards, pass or fail; any other instance is refused. | @@ -58,7 +58,7 @@ destructive test-rig commands still require care. `tests/framework/` contains framework-level tests. Most are offline unit tests. `tests/framework/test_config_to_container.py` is intentionally separate because it talks to Docker and dominates runtime. `tests/stacklets/test_backup_e2e.py` -joins it in the lifecycle lane: it creates and mounts an APFS disk image per +joins it in the integration lane: it creates and mounts an APFS disk image per test, about a second each, which was a third of the unit lane. The list lives in `script/test` as `SLOW_FILES`. diff --git a/tests/framework/fixtures/guest/stacklet.toml b/tests/framework/fixtures/guest/stacklet.toml index 538bb9a9..59d8fdb8 100644 --- a/tests/framework/fixtures/guest/stacklet.toml +++ b/tests/framework/fixtures/guest/stacklet.toml @@ -2,7 +2,7 @@ # # The same shape as the `test` fixture, but installed into the instance's # extensions dir instead of stacklets/: a stacklet the release does not -# ship. It carries a stage so the lifecycle lane sees the warning path +# ship. It carries a stage so the integration lane sees the warning path # too, which is the realistic pairing — a stacklet usually lives outside # the repo because it is not finished. From c5224585eb7c6184bedffe67aa78c30fdeda70a6 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 09:09:23 +0200 Subject: [PATCH 7/8] test: lay the test directories out by lane Each lane is now a directory, so where a test lives says what it costs: - `tests/unit/{framework,stacklets}`: the unit lane, offline - `tests/integration`: the Docker lifecycle and the APFS backup engine, which drive the host rather than Python - `tests/e2e`: demo, smoke, e2e and eval, run through `tests/e2e/stacktests` (was `tests/integration/stacktests`) The unit lane no longer needs an ignore list, and `pytest tests/unit` can no longer start Docker by accident, which running `pytest tests/framework` used to do. Unit tests sit one level deeper, so the ones that locate the repo root count one more parent. --- .gitignore | 6 +-- docs/adr/adr-011-vault-brain-projection.md | 2 +- docs/adr/adr-012-nanobot-fork.md | 2 +- ...-002-installation-instances-and-updates.md | 2 +- .../rfc-003-agentic-development-harness.md | 2 +- docs/agent/dev.md | 8 +-- docs/design-notes.md | 2 +- docs/design/agent/addressing.md | 2 +- docs/design/agent/plan.md | 12 ++--- docs/design/brain/content-pattern.md | 2 +- docs/design/brain/plan.md | 20 ++++---- docs/design/brain/topic-rooms.md | 12 ++--- docs/design/brain/who-answers.md | 8 +-- docs/stack-reference.md | 4 +- docs/todos/refactor-cleanup-plan.md | 2 +- lib/stack/doctor.py | 2 +- pyproject.toml | 4 +- script/test | 20 +++----- stacklets/agent/runtime/sitecustomize.py | 2 +- .../backup/engines/external-disk/README.md | 4 +- stacklets/docs/bot/cli/capture.py | 2 +- stacklets/memory/bot/cli/todo_list.py | 2 +- stacklets/memory/bot/curator.py | 2 +- stacklets/memory/cli/check.py | 2 +- stacklets/memory/seeds/ontology.toml | 2 +- tests/README.md | 42 ++++++++------- tests/{framework => e2e}/__init__.py | 0 tests/{integration => e2e}/_ai_mode.py | 4 +- tests/{integration => e2e}/_check_versions.py | 0 tests/{integration => e2e}/_seed_secrets.py | 6 +-- tests/{integration => e2e}/bdd.py | 0 tests/{integration => e2e}/conftest.py | 20 ++++---- tests/{integration => e2e}/eval/__init__.py | 2 +- tests/{integration => e2e}/eval/_artifacts.py | 0 .../eval/cases/grundriss-flaechen.png | Bin .../eval/cases/grundriss-flaechen.yaml | 0 .../eval/cases/kassenbon-kaeserei.pdf | Bin .../eval/cases/kassenbon-kaeserei.yaml | 0 .../eval/cases/kassenbon-obst.pdf | Bin .../eval/cases/kassenbon-obst.yaml | 0 .../eval/cases/rechnung.png | Bin .../eval/cases/rechnung.yaml | 0 .../eval/cases/rezept-fisch.pdf | Bin .../eval/cases/rezept-fisch.yaml | 0 .../eval/cases/rezept-kuchen.pdf | Bin .../eval/cases/rezept-kuchen.yaml | 0 .../eval/cases/rezept-lasagne.pdf | Bin .../eval/cases/rezept-lasagne.yaml | 0 .../eval/cases/tankbeleg-esso.png | Bin .../eval/cases/tankbeleg-esso.yaml | 0 tests/{integration => e2e}/eval/conftest.py | 6 +-- tests/{integration => e2e}/eval/scoring.py | 0 .../eval/test_classify_eval.py | 2 +- tests/{integration => e2e}/forgejo.py | 0 .../{integration => e2e}/instance/.gitignore | 0 .../{integration => e2e}/instance/stack.toml | 2 +- .../{integration => e2e}/instance/users.toml | 0 tests/{integration => e2e}/matrix.py | 2 +- tests/{integration => e2e}/openai_stub.py | 0 tests/{integration => e2e}/paperless.py | 0 tests/{integration => e2e}/stacktests | 48 +++++++++--------- .../test_archivist_e2e.py | 6 +-- .../test_archivist_paperless_api_e2e.py | 6 +-- .../test_bot_arbitration_e2e.py | 2 +- .../test_capture_binary_e2e.py | 8 +-- .../test_capture_memory_e2e.py | 10 ++-- .../{integration => e2e}/test_demo_rig_e2e.py | 8 +-- .../test_email_imap_e2e.py | 0 .../test_git_mirror_e2e.py | 8 +-- .../test_markdown_mirror_e2e.py | 6 +-- .../test_microbot_transport_e2e.py | 8 +-- tests/{integration => e2e}/test_rig.py | 6 +-- .../test_room_context_e2e.py | 4 +- .../test_room_modes_e2e.py | 2 +- .../test_source_archive_e2e.py | 8 +-- .../test_state_api_e2e.py | 2 +- .../test_backup_e2e.py | 6 +-- .../test_config_to_container.py | 2 +- tests/unit/__init__.py | 0 tests/unit/framework/__init__.py | 0 tests/{ => unit}/framework/conftest.py | 4 +- .../framework/fixtures/basic/stacklet.toml | 0 .../fixtures/guest/docker-compose.yml | 0 .../fixtures/guest/hooks/on_install.py | 0 .../framework/fixtures/guest/stacklet.toml | 0 .../fixtures/incubating/stacklet.toml | 0 .../fixtures/test/docker-compose.yml | 0 .../fixtures/test/hooks/on_install.py | 0 .../fixtures/test/hooks/on_install_success.py | 0 .../framework/fixtures/test/stacklet.toml | 0 .../fixtures/with_deps/stacklet.toml | 0 .../fixtures/with_hooks/hooks/on_install.py | 0 .../fixtures/with_hooks/hooks/on_stop.sh | 0 .../fixtures/with_hooks/stacklet.toml | 0 .../fixtures/with_secrets/stacklet.toml | 0 tests/{ => unit}/framework/test_ai_backend.py | 2 +- tests/{ => unit}/framework/test_ai_client.py | 2 +- tests/{ => unit}/framework/test_ai_mode.py | 2 +- .../{ => unit}/framework/test_ai_on_start.py | 2 +- .../framework/test_ai_transcripts.py | 2 +- .../{ => unit}/framework/test_capabilities.py | 2 +- .../framework/test_check_versions.py | 4 +- tests/{ => unit}/framework/test_cli_class.py | 2 +- tests/{ => unit}/framework/test_commands.py | 2 +- .../{ => unit}/framework/test_commit_lint.py | 2 +- .../framework/test_compose_bind_mounts.py | 2 +- .../{ => unit}/framework/test_compose_pins.py | 2 +- .../{ => unit}/framework/test_dependencies.py | 0 tests/{ => unit}/framework/test_discovery.py | 0 tests/{ => unit}/framework/test_docker_ids.py | 0 .../framework/test_docker_runtime.py | 0 tests/{ => unit}/framework/test_doctor.py | 0 .../framework/test_env_rendering.py | 0 tests/{ => unit}/framework/test_extensions.py | 2 +- .../{ => unit}/framework/test_family_name.py | 2 +- .../framework/test_health_check_skip.py | 2 +- .../framework/test_hook_ctx_shell.py | 2 +- .../framework/test_hook_resolver.py | 2 +- .../{ => unit}/framework/test_hook_secrets.py | 0 .../framework/test_hook_shell_env.py | 2 +- tests/{ => unit}/framework/test_hooks.py | 0 tests/{ => unit}/framework/test_links.py | 2 +- tests/{ => unit}/framework/test_list_doc.py | 2 +- tests/{ => unit}/framework/test_mail_env.py | 2 +- .../framework/test_media_archive.py | 0 .../framework/test_microbot_cursor.py | 4 +- tests/{ => unit}/framework/test_models.py | 2 +- tests/{ => unit}/framework/test_ontology.py | 0 tests/{ => unit}/framework/test_page_patch.py | 2 +- .../framework/test_postgres_helper.py | 2 +- tests/{ => unit}/framework/test_prompt.py | 0 .../{ => unit}/framework/test_secret_store.py | 2 +- tests/{ => unit}/framework/test_secrets.py | 0 .../{ => unit}/framework/test_stack_class.py | 2 +- .../{ => unit}/framework/test_stack_health.py | 2 +- .../framework/test_stack_lifecycle.py | 2 +- .../{ => unit}/framework/test_stack_output.py | 2 +- .../framework/test_stack_secrets.py | 2 +- .../framework/test_strip_mention.py | 2 +- tests/{ => unit}/framework/test_uninstall.py | 2 +- tests/{ => unit}/framework/test_updater.py | 0 tests/{ => unit}/framework/test_user_id.py | 2 +- tests/{ => unit}/framework/test_web_fetch.py | 0 .../{ => unit}/framework/test_web_profiles.py | 0 .../{ => unit}/framework/test_web_quality.py | 0 .../framework/test_web_structured.py | 0 tests/{ => unit}/stacklets/conftest.py | 2 +- .../{ => unit}/stacklets/test_agent_config.py | 2 +- .../stacklets/test_agent_join_greeting.py | 2 +- .../stacklets/test_agent_name_trigger.py | 2 +- .../stacklets/test_agent_runtime_shims.py | 0 .../stacklets/test_agent_thread_trigger.py | 2 +- .../stacklets/test_agent_vault_tools.py | 2 +- .../stacklets/test_ai_omlx_install.py | 2 +- .../stacklets/test_ai_start_guard.py | 2 +- .../stacklets/test_archivist_corrections.py | 2 +- .../stacklets/test_archivist_duplicate.py | 2 +- .../stacklets/test_archivist_matching.py | 2 +- .../stacklets/test_archivist_routing.py | 4 +- .../stacklets/test_archivist_source.py | 2 +- .../test_archivist_topic_bootstrap.py | 2 +- .../stacklets/test_archivist_welcome.py | 2 +- .../stacklets/test_backup_config.py | 2 +- .../stacklets/test_backup_configure.py | 2 +- .../{ => unit}/stacklets/test_backup_cron.py | 2 +- .../stacklets/test_backup_engine.py | 2 +- .../stacklets/test_backup_install.py | 2 +- .../stacklets/test_backup_migrate.py | 2 +- .../stacklets/test_backup_orchestrator.py | 2 +- .../stacklets/test_backup_snapshot.py | 2 +- .../stacklets/test_backup_status.py | 2 +- .../stacklets/test_bot_discovery.py | 2 +- .../stacklets/test_capture_links.py | 2 +- .../stacklets/test_capture_pipeline.py | 2 +- .../stacklets/test_capture_prompt.py | 2 +- .../stacklets/test_capture_tag_cache.py | 2 +- .../test_classify_prompt_ontology.py | 4 +- .../{ => unit}/stacklets/test_cli_id_specs.py | 2 +- .../stacklets/test_core_link_resolver.py | 2 +- .../{ => unit}/stacklets/test_diary_store.py | 2 +- tests/{ => unit}/stacklets/test_docs_seed.py | 2 +- tests/{ => unit}/stacklets/test_docs_token.py | 2 +- .../stacklets/test_document_pipeline.py | 2 +- tests/{ => unit}/stacklets/test_extractors.py | 2 +- .../{ => unit}/stacklets/test_frontmatter.py | 0 tests/{ => unit}/stacklets/test_git_mirror.py | 2 +- tests/{ => unit}/stacklets/test_mailbot.py | 2 +- .../stacklets/test_matrix_client.py | 2 +- tests/{ => unit}/stacklets/test_memory_ask.py | 2 +- .../stacklets/test_memory_capture_cli.py | 2 +- tests/{ => unit}/stacklets/test_memory_cli.py | 2 +- .../stacklets/test_memory_correspondents.py | 2 +- .../stacklets/test_memory_curator.py | 2 +- .../stacklets/test_memory_curator_sync.py | 2 +- .../{ => unit}/stacklets/test_memory_diary.py | 2 +- .../stacklets/test_memory_history_cli.py | 2 +- .../stacklets/test_memory_host_stdlib.py | 2 +- .../stacklets/test_memory_install_pipeline.py | 2 +- .../stacklets/test_memory_install_seeds.py | 2 +- .../stacklets/test_memory_list_edit.py | 2 +- .../stacklets/test_memory_persons.py | 2 +- .../stacklets/test_memory_query_rewrite.py | 2 +- .../stacklets/test_memory_search.py | 4 +- .../stacklets/test_memory_search_nl.py | 2 +- .../{ => unit}/stacklets/test_memory_sync.py | 2 +- .../stacklets/test_memory_token_repair.py | 2 +- .../{ => unit}/stacklets/test_memory_vault.py | 2 +- .../{ => unit}/stacklets/test_memory_wiki.py | 2 +- .../stacklets/test_memory_write_cli.py | 2 +- .../test_messages_matrix_power_levels.py | 2 +- .../stacklets/test_messages_rate_limits.py | 2 +- .../stacklets/test_messages_send_as.py | 2 +- .../stacklets/test_messages_setup.py | 2 +- .../stacklets/test_messages_threading.py | 2 +- tests/{ => unit}/stacklets/test_microbot.py | 4 +- .../stacklets/test_microbot_source.py | 2 +- .../stacklets/test_microbot_voice.py | 4 +- tests/{ => unit}/stacklets/test_nl_query.py | 2 +- tests/{ => unit}/stacklets/test_notifier.py | 2 +- .../{ => unit}/stacklets/test_omlx_client.py | 2 +- .../{ => unit}/stacklets/test_pdf_analysis.py | 2 +- tests/{ => unit}/stacklets/test_pdf_render.py | 2 +- .../stacklets/test_photos_import.py | 4 +- tests/{ => unit}/stacklets/test_pipeline.py | 6 +-- tests/{ => unit}/stacklets/test_recall.py | 2 +- .../stacklets/test_reply_presenter.py | 2 +- .../{ => unit}/stacklets/test_room_context.py | 2 +- tests/{ => unit}/stacklets/test_scribe.py | 2 +- .../stacklets/test_search_format.py | 2 +- .../stacklets/test_search_service.py | 2 +- .../stacklets/test_source_archive.py | 2 +- tests/{ => unit}/stacklets/test_stacker.py | 2 +- tests/{ => unit}/stacklets/test_state_log.py | 2 +- tests/{ => unit}/stacklets/test_text_utils.py | 2 +- tests/{ => unit}/stacklets/test_todo_list.py | 2 +- .../{ => unit}/stacklets/test_topic_rooms.py | 2 +- .../stacklets/test_vault_context.py | 2 +- .../{ => unit}/stacklets/test_vault_entry.py | 2 +- 238 files changed, 314 insertions(+), 312 deletions(-) rename tests/{framework => e2e}/__init__.py (100%) rename tests/{integration => e2e}/_ai_mode.py (98%) rename tests/{integration => e2e}/_check_versions.py (100%) rename tests/{integration => e2e}/_seed_secrets.py (96%) rename tests/{integration => e2e}/bdd.py (100%) rename tests/{integration => e2e}/conftest.py (96%) rename tests/{integration => e2e}/eval/__init__.py (89%) rename tests/{integration => e2e}/eval/_artifacts.py (100%) rename tests/{integration => e2e}/eval/cases/grundriss-flaechen.png (100%) rename tests/{integration => e2e}/eval/cases/grundriss-flaechen.yaml (100%) rename tests/{integration => e2e}/eval/cases/kassenbon-kaeserei.pdf (100%) rename tests/{integration => e2e}/eval/cases/kassenbon-kaeserei.yaml (100%) rename tests/{integration => e2e}/eval/cases/kassenbon-obst.pdf (100%) rename tests/{integration => e2e}/eval/cases/kassenbon-obst.yaml (100%) rename tests/{integration => e2e}/eval/cases/rechnung.png (100%) rename tests/{integration => e2e}/eval/cases/rechnung.yaml (100%) rename tests/{integration => e2e}/eval/cases/rezept-fisch.pdf (100%) rename tests/{integration => e2e}/eval/cases/rezept-fisch.yaml (100%) rename tests/{integration => e2e}/eval/cases/rezept-kuchen.pdf (100%) rename tests/{integration => e2e}/eval/cases/rezept-kuchen.yaml (100%) rename tests/{integration => e2e}/eval/cases/rezept-lasagne.pdf (100%) rename tests/{integration => e2e}/eval/cases/rezept-lasagne.yaml (100%) rename tests/{integration => e2e}/eval/cases/tankbeleg-esso.png (100%) rename tests/{integration => e2e}/eval/cases/tankbeleg-esso.yaml (100%) rename tests/{integration => e2e}/eval/conftest.py (98%) rename tests/{integration => e2e}/eval/scoring.py (100%) rename tests/{integration => e2e}/eval/test_classify_eval.py (99%) rename tests/{integration => e2e}/forgejo.py (100%) rename tests/{integration => e2e}/instance/.gitignore (100%) rename tests/{integration => e2e}/instance/stack.toml (93%) rename tests/{integration => e2e}/instance/users.toml (100%) rename tests/{integration => e2e}/matrix.py (99%) rename tests/{integration => e2e}/openai_stub.py (100%) rename tests/{integration => e2e}/paperless.py (100%) rename tests/{integration => e2e}/stacktests (91%) rename tests/{integration => e2e}/test_archivist_e2e.py (99%) rename tests/{integration => e2e}/test_archivist_paperless_api_e2e.py (98%) rename tests/{integration => e2e}/test_bot_arbitration_e2e.py (99%) rename tests/{integration => e2e}/test_capture_binary_e2e.py (99%) rename tests/{integration => e2e}/test_capture_memory_e2e.py (96%) rename tests/{integration => e2e}/test_demo_rig_e2e.py (99%) rename tests/{integration => e2e}/test_email_imap_e2e.py (100%) rename tests/{integration => e2e}/test_git_mirror_e2e.py (97%) rename tests/{integration => e2e}/test_markdown_mirror_e2e.py (97%) rename tests/{integration => e2e}/test_microbot_transport_e2e.py (96%) rename tests/{integration => e2e}/test_rig.py (94%) rename tests/{integration => e2e}/test_room_context_e2e.py (98%) rename tests/{integration => e2e}/test_room_modes_e2e.py (99%) rename tests/{integration => e2e}/test_source_archive_e2e.py (97%) rename tests/{integration => e2e}/test_state_api_e2e.py (97%) rename tests/{stacklets => integration}/test_backup_e2e.py (99%) rename tests/{framework => integration}/test_config_to_container.py (99%) create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/framework/__init__.py rename tests/{ => unit}/framework/conftest.py (96%) rename tests/{ => unit}/framework/fixtures/basic/stacklet.toml (100%) rename tests/{ => unit}/framework/fixtures/guest/docker-compose.yml (100%) rename tests/{ => unit}/framework/fixtures/guest/hooks/on_install.py (100%) rename tests/{ => unit}/framework/fixtures/guest/stacklet.toml (100%) rename tests/{ => unit}/framework/fixtures/incubating/stacklet.toml (100%) rename tests/{ => unit}/framework/fixtures/test/docker-compose.yml (100%) rename tests/{ => unit}/framework/fixtures/test/hooks/on_install.py (100%) rename tests/{ => unit}/framework/fixtures/test/hooks/on_install_success.py (100%) rename tests/{ => unit}/framework/fixtures/test/stacklet.toml (100%) rename tests/{ => unit}/framework/fixtures/with_deps/stacklet.toml (100%) rename tests/{ => unit}/framework/fixtures/with_hooks/hooks/on_install.py (100%) rename tests/{ => unit}/framework/fixtures/with_hooks/hooks/on_stop.sh (100%) rename tests/{ => unit}/framework/fixtures/with_hooks/stacklet.toml (100%) rename tests/{ => unit}/framework/fixtures/with_secrets/stacklet.toml (100%) rename tests/{ => unit}/framework/test_ai_backend.py (98%) rename tests/{ => unit}/framework/test_ai_client.py (99%) rename tests/{ => unit}/framework/test_ai_mode.py (98%) rename tests/{ => unit}/framework/test_ai_on_start.py (97%) rename tests/{ => unit}/framework/test_ai_transcripts.py (99%) rename tests/{ => unit}/framework/test_capabilities.py (98%) rename tests/{ => unit}/framework/test_check_versions.py (95%) rename tests/{ => unit}/framework/test_cli_class.py (99%) rename tests/{ => unit}/framework/test_commands.py (98%) rename tests/{ => unit}/framework/test_commit_lint.py (99%) rename tests/{ => unit}/framework/test_compose_bind_mounts.py (98%) rename tests/{ => unit}/framework/test_compose_pins.py (98%) rename tests/{ => unit}/framework/test_dependencies.py (100%) rename tests/{ => unit}/framework/test_discovery.py (100%) rename tests/{ => unit}/framework/test_docker_ids.py (100%) rename tests/{ => unit}/framework/test_docker_runtime.py (100%) rename tests/{ => unit}/framework/test_doctor.py (100%) rename tests/{ => unit}/framework/test_env_rendering.py (100%) rename tests/{ => unit}/framework/test_extensions.py (99%) rename tests/{ => unit}/framework/test_family_name.py (96%) rename tests/{ => unit}/framework/test_health_check_skip.py (98%) rename tests/{ => unit}/framework/test_hook_ctx_shell.py (97%) rename tests/{ => unit}/framework/test_hook_resolver.py (99%) rename tests/{ => unit}/framework/test_hook_secrets.py (100%) rename tests/{ => unit}/framework/test_hook_shell_env.py (97%) rename tests/{ => unit}/framework/test_hooks.py (100%) rename tests/{ => unit}/framework/test_links.py (98%) rename tests/{ => unit}/framework/test_list_doc.py (99%) rename tests/{ => unit}/framework/test_mail_env.py (99%) rename tests/{ => unit}/framework/test_media_archive.py (100%) rename tests/{ => unit}/framework/test_microbot_cursor.py (98%) rename tests/{ => unit}/framework/test_models.py (98%) rename tests/{ => unit}/framework/test_ontology.py (100%) rename tests/{ => unit}/framework/test_page_patch.py (98%) rename tests/{ => unit}/framework/test_postgres_helper.py (99%) rename tests/{ => unit}/framework/test_prompt.py (100%) rename tests/{ => unit}/framework/test_secret_store.py (98%) rename tests/{ => unit}/framework/test_secrets.py (100%) rename tests/{ => unit}/framework/test_stack_class.py (99%) rename tests/{ => unit}/framework/test_stack_health.py (98%) rename tests/{ => unit}/framework/test_stack_lifecycle.py (99%) rename tests/{ => unit}/framework/test_stack_output.py (98%) rename tests/{ => unit}/framework/test_stack_secrets.py (98%) rename tests/{ => unit}/framework/test_strip_mention.py (98%) rename tests/{ => unit}/framework/test_uninstall.py (99%) rename tests/{ => unit}/framework/test_updater.py (100%) rename tests/{ => unit}/framework/test_user_id.py (95%) rename tests/{ => unit}/framework/test_web_fetch.py (100%) rename tests/{ => unit}/framework/test_web_profiles.py (100%) rename tests/{ => unit}/framework/test_web_quality.py (100%) rename tests/{ => unit}/framework/test_web_structured.py (100%) rename tests/{ => unit}/stacklets/conftest.py (99%) rename tests/{ => unit}/stacklets/test_agent_config.py (93%) rename tests/{ => unit}/stacklets/test_agent_join_greeting.py (98%) rename tests/{ => unit}/stacklets/test_agent_name_trigger.py (98%) rename tests/{ => unit}/stacklets/test_agent_runtime_shims.py (100%) rename tests/{ => unit}/stacklets/test_agent_thread_trigger.py (99%) rename tests/{ => unit}/stacklets/test_agent_vault_tools.py (99%) rename tests/{ => unit}/stacklets/test_ai_omlx_install.py (98%) rename tests/{ => unit}/stacklets/test_ai_start_guard.py (97%) rename tests/{ => unit}/stacklets/test_archivist_corrections.py (99%) rename tests/{ => unit}/stacklets/test_archivist_duplicate.py (97%) rename tests/{ => unit}/stacklets/test_archivist_matching.py (99%) rename tests/{ => unit}/stacklets/test_archivist_routing.py (99%) rename tests/{ => unit}/stacklets/test_archivist_source.py (99%) rename tests/{ => unit}/stacklets/test_archivist_topic_bootstrap.py (99%) rename tests/{ => unit}/stacklets/test_archivist_welcome.py (99%) rename tests/{ => unit}/stacklets/test_backup_config.py (99%) rename tests/{ => unit}/stacklets/test_backup_configure.py (99%) rename tests/{ => unit}/stacklets/test_backup_cron.py (99%) rename tests/{ => unit}/stacklets/test_backup_engine.py (99%) rename tests/{ => unit}/stacklets/test_backup_install.py (98%) rename tests/{ => unit}/stacklets/test_backup_migrate.py (99%) rename tests/{ => unit}/stacklets/test_backup_orchestrator.py (99%) rename tests/{ => unit}/stacklets/test_backup_snapshot.py (99%) rename tests/{ => unit}/stacklets/test_backup_status.py (98%) rename tests/{ => unit}/stacklets/test_bot_discovery.py (92%) rename tests/{ => unit}/stacklets/test_capture_links.py (99%) rename tests/{ => unit}/stacklets/test_capture_pipeline.py (99%) rename tests/{ => unit}/stacklets/test_capture_prompt.py (99%) rename tests/{ => unit}/stacklets/test_capture_tag_cache.py (97%) rename tests/{ => unit}/stacklets/test_classify_prompt_ontology.py (99%) rename tests/{ => unit}/stacklets/test_cli_id_specs.py (98%) rename tests/{ => unit}/stacklets/test_core_link_resolver.py (99%) rename tests/{ => unit}/stacklets/test_diary_store.py (95%) rename tests/{ => unit}/stacklets/test_docs_seed.py (96%) rename tests/{ => unit}/stacklets/test_docs_token.py (98%) rename tests/{ => unit}/stacklets/test_document_pipeline.py (99%) rename tests/{ => unit}/stacklets/test_extractors.py (99%) rename tests/{ => unit}/stacklets/test_frontmatter.py (100%) rename tests/{ => unit}/stacklets/test_git_mirror.py (99%) rename tests/{ => unit}/stacklets/test_mailbot.py (99%) rename tests/{ => unit}/stacklets/test_matrix_client.py (98%) rename tests/{ => unit}/stacklets/test_memory_ask.py (99%) rename tests/{ => unit}/stacklets/test_memory_capture_cli.py (99%) rename tests/{ => unit}/stacklets/test_memory_cli.py (99%) rename tests/{ => unit}/stacklets/test_memory_correspondents.py (99%) rename tests/{ => unit}/stacklets/test_memory_curator.py (99%) rename tests/{ => unit}/stacklets/test_memory_curator_sync.py (99%) rename tests/{ => unit}/stacklets/test_memory_diary.py (99%) rename tests/{ => unit}/stacklets/test_memory_history_cli.py (99%) rename tests/{ => unit}/stacklets/test_memory_host_stdlib.py (98%) rename tests/{ => unit}/stacklets/test_memory_install_pipeline.py (99%) rename tests/{ => unit}/stacklets/test_memory_install_seeds.py (99%) rename tests/{ => unit}/stacklets/test_memory_list_edit.py (98%) rename tests/{ => unit}/stacklets/test_memory_persons.py (99%) rename tests/{ => unit}/stacklets/test_memory_query_rewrite.py (99%) rename tests/{ => unit}/stacklets/test_memory_search.py (99%) rename tests/{ => unit}/stacklets/test_memory_search_nl.py (99%) rename tests/{ => unit}/stacklets/test_memory_sync.py (99%) rename tests/{ => unit}/stacklets/test_memory_token_repair.py (99%) rename tests/{ => unit}/stacklets/test_memory_vault.py (99%) rename tests/{ => unit}/stacklets/test_memory_wiki.py (99%) rename tests/{ => unit}/stacklets/test_memory_write_cli.py (99%) rename tests/{ => unit}/stacklets/test_messages_matrix_power_levels.py (99%) rename tests/{ => unit}/stacklets/test_messages_rate_limits.py (97%) rename tests/{ => unit}/stacklets/test_messages_send_as.py (94%) rename tests/{ => unit}/stacklets/test_messages_setup.py (98%) rename tests/{ => unit}/stacklets/test_messages_threading.py (98%) rename tests/{ => unit}/stacklets/test_microbot.py (99%) rename tests/{ => unit}/stacklets/test_microbot_source.py (98%) rename tests/{ => unit}/stacklets/test_microbot_voice.py (99%) rename tests/{ => unit}/stacklets/test_nl_query.py (99%) rename tests/{ => unit}/stacklets/test_notifier.py (97%) rename tests/{ => unit}/stacklets/test_omlx_client.py (98%) rename tests/{ => unit}/stacklets/test_pdf_analysis.py (99%) rename tests/{ => unit}/stacklets/test_pdf_render.py (98%) rename tests/{ => unit}/stacklets/test_photos_import.py (98%) rename tests/{ => unit}/stacklets/test_pipeline.py (99%) rename tests/{ => unit}/stacklets/test_recall.py (99%) rename tests/{ => unit}/stacklets/test_reply_presenter.py (99%) rename tests/{ => unit}/stacklets/test_room_context.py (99%) rename tests/{ => unit}/stacklets/test_scribe.py (98%) rename tests/{ => unit}/stacklets/test_search_format.py (99%) rename tests/{ => unit}/stacklets/test_search_service.py (98%) rename tests/{ => unit}/stacklets/test_source_archive.py (98%) rename tests/{ => unit}/stacklets/test_stacker.py (97%) rename tests/{ => unit}/stacklets/test_state_log.py (99%) rename tests/{ => unit}/stacklets/test_text_utils.py (99%) rename tests/{ => unit}/stacklets/test_todo_list.py (99%) rename tests/{ => unit}/stacklets/test_topic_rooms.py (99%) rename tests/{ => unit}/stacklets/test_vault_context.py (96%) rename tests/{ => unit}/stacklets/test_vault_entry.py (99%) diff --git a/.gitignore b/.gitignore index cdc6a04e..0e922566 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,8 @@ stack.toml config.sh users.toml # …except the test instance's config, which is checked in. -!tests/integration/instance/stack.toml -!tests/integration/instance/users.toml +!tests/e2e/instance/stack.toml +!tests/e2e/instance/users.toml # Runtime state — generated by stack CLI .stack/ @@ -44,7 +44,7 @@ htmlcov/ # Pipeline-eval per-run artifacts (OCR text, classification JSON, # scorecards) — written by `stacktests eval`, never committed. -tests/integration/eval/runs/ +tests/e2e/eval/runs/ # AI assistant. Local settings and transcripts stay out, but the LSP plugin # is repo configuration: it makes agents read this codebase with a language diff --git a/docs/adr/adr-011-vault-brain-projection.md b/docs/adr/adr-011-vault-brain-projection.md index 608cf23c..0e899136 100644 --- a/docs/adr/adr-011-vault-brain-projection.md +++ b/docs/adr/adr-011-vault-brain-projection.md @@ -101,5 +101,5 @@ agent fell back to grep on its `vault/` mount, which is the brain. Cost: a page filed seconds ago is findable after the next mirror tick, not at once. Read-your-writes is tested for todos only -(`tests/integration/test_demo_rig_e2e.py`), so no stated invariant changes. +(`tests/e2e/test_demo_rig_e2e.py`), so no stated invariant changes. diff --git a/docs/adr/adr-012-nanobot-fork.md b/docs/adr/adr-012-nanobot-fork.md index edef51a6..641bd846 100644 --- a/docs/adr/adr-012-nanobot-fork.md +++ b/docs/adr/adr-012-nanobot-fork.md @@ -53,7 +53,7 @@ supposed to have replaced it has a third state, and that third state is silent. ### 2. The shim tests cannot catch the thing that actually breaks shims -`tests/stacklets/test_agent_runtime_shims.py` asserts every patch is attached, +`tests/unit/stacklets/test_agent_runtime_shims.py` asserts every patch is attached, which is real value: it catches *our* mistakes. It cannot catch upstream moving a symbol, because it runs against a stub nanobot this repo hand-writes. The stub still has the old symbol, so the lane stays green while production is diff --git a/docs/adr/rfc-002-installation-instances-and-updates.md b/docs/adr/rfc-002-installation-instances-and-updates.md index 5e1822ea..63424ee0 100644 --- a/docs/adr/rfc-002-installation-instances-and-updates.md +++ b/docs/adr/rfc-002-installation-instances-and-updates.md @@ -168,7 +168,7 @@ any compose project named `stack-*` to a stacklet id. A second instance would report the first instance's containers as its own, so `stack list` lies before anything collides. -This is why `tests/integration/stacktests` takes the dev instance down rather +This is why `tests/e2e/stacktests` takes the dev instance down rather than running beside it, and why the operator docs say two instances cannot run on one Mac. diff --git a/docs/adr/rfc-003-agentic-development-harness.md b/docs/adr/rfc-003-agentic-development-harness.md index 9cdcafda..0ae78344 100644 --- a/docs/adr/rfc-003-agentic-development-harness.md +++ b/docs/adr/rfc-003-agentic-development-harness.md @@ -121,7 +121,7 @@ its own containers, both reachable. ### 5. An instance that says which machine it is (an afternoon) -The rig already has half of this. `tests/integration/stacktests` refuses to +The rig already has half of this. `tests/e2e/stacktests` refuses to run over a real stack unless `.stack/.test-instance` is present, and the comment above it says why: "running the rig over someone's real stack errors out instead of overwriting it". That sentinel protects one script. diff --git a/docs/agent/dev.md b/docs/agent/dev.md index c9a77ea6..03cff776 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -232,17 +232,17 @@ Testing rules: - **Do not write tests that mirror the implementation.** A test written next to the code it covers proves the two agree, not that either is right. Assert against something external: a spec, a real service's response, an invariant we promise. If a test could only fail when someone changes their mind, delete it. - **Behavioural TDD: RED then GREEN.** Write the failing test that captures the behaviour you want; make it pass with the smallest change; then refactor. - **Blackbox at the module boundary.** Test what a module promises through its public surface. Mock only external interfaces (network endpoints, the LLM), and only when truly required. -- **Prefer a real model over a stubbed one.** `tests/integration/stacktests ai local` points the rig at a self-hosted endpoint: real answers, no cost per call, only slower. A green run against a stub proves the wiring, not the behaviour. +- **Prefer a real model over a stubbed one.** `tests/e2e/stacktests ai local` points the rig at a self-hosted endpoint: real answers, no cost per call, only slower. A green run against a stub proves the wiring, not the behaviour. - **Seed through the front door.** See [AGENTS.md non-negotiable 11](../../AGENTS.md). To populate an instance with documents, run `tools/family-docs/ingest.py`: it posts each rendered demo document into the Matrix `documents` room as the family member who would plausibly have sent it, so the archivist picks it up and the whole pipeline runs. Uploading to `POST /api/documents/post_document/` instead is faster and produces the wrong corpus - bare OCR text with a filename for a title, no tags, correspondent, document type, summary note, or vault entry. Search, classification, and mirror behaviour all read differently against that, so measurements taken on it are worthless. The same rule holds for every stacklet: drive the user-facing surface, not the service behind it. -- **Docker integration tests when warranted.** If a change crosses a container boundary or depends on a real service's behaviour, add a test under `tests/integration/`. +- **Docker integration tests when warranted.** If a change crosses a container boundary or depends on a real service's behaviour, add a test under `tests/e2e/`. - **Tests run against real stacklets and real hooks.** No parallel test-only compose files. - **Use real Synapse via the `messages` stacklet.** No handwritten Matrix mocks. - **Allowed mocks: loggers only.** Real imports catch real errors. - **Established deps over handwritten fakes:** `pytest-httpserver` for HTTP, real services for the rest. - **Test helpers do one thing.** Add a parameter only when a second test needs it - not preemptively. -- **`tests/integration/eval/` is opt-in** (slow, real model). Excluded from `pytest tests/` by `norecursedirs`. +- **`tests/e2e/eval/` is opt-in** (slow, real model). Excluded from `pytest tests/` by `norecursedirs`. - **Write tests before fixing.** No duct tape. -- **The rig is shared, not off-limits.** This repo root is the Simpsons dev instance, not anyone's real famstack, so agents may run the rig lanes. Ports are fixed, so exactly one run at a time: check nothing else is mid-run before starting. `tests/integration/stacktests help` lists which subcommands are autonomous, shared, or destructive. +- **The rig is shared, not off-limits.** This repo root is the Simpsons dev instance, not anyone's real famstack, so agents may run the rig lanes. Ports are fixed, so exactly one run at a time: check nothing else is mid-run before starting. `tests/e2e/stacktests help` lists which subcommands are autonomous, shared, or destructive. ## Code style diff --git a/docs/design-notes.md b/docs/design-notes.md index 6e849176..9e19ad28 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -48,7 +48,7 @@ the same one.** Half a relation is worse than none, because the send side looks right in the client while routing silently degrades. Also why it survived a release: the intent spec that would have caught it -(`tests/integration/test_room_modes_e2e.py`) is marked +(`tests/e2e/test_room_modes_e2e.py`) is marked `xfail(strict=False)`, which is green when broken and green when fixed. A non-strict xfail is not coverage. Making it `strict=True`, skipping it with a reason, or deleting it are all better; the action is FAM-6. diff --git a/docs/design/agent/addressing.md b/docs/design/agent/addressing.md index e0739630..06c23d25 100644 --- a/docs/design/agent/addressing.md +++ b/docs/design/agent/addressing.md @@ -71,7 +71,7 @@ Make a **thread the conversation unit** in shared rooms. Three steps: 2. Treat a message in a thread **whose root Stacky authored, or where Stacky has already posted**, as addressed. **Shipped** as the `thread_trigger` shim (`stacklets/agent/runtime/thread_trigger.py`), specified by - `tests/stacklets/test_agent_thread_trigger.py`. + `tests/unit/stacklets/test_agent_thread_trigger.py`. Two halves, because nanobot's gate is synchronous and the question is not: `_on_message` (async) settles thread membership against the homeserver and diff --git a/docs/design/agent/plan.md b/docs/design/agent/plan.md index 7f012a0a..e19fe6d3 100644 --- a/docs/design/agent/plan.md +++ b/docs/design/agent/plan.md @@ -150,8 +150,8 @@ Files (framework-agnostic shape, fills in concretely after Phase 0): Tests: -- `tests/stacklets/test_vault_search.py` — search returns expected paths for known fixtures. -- `tests/integration/test_agent_qa_e2e.py` — file a doc, ask a question, assert citation appears in the reply. +- `tests/unit/stacklets/test_vault_search.py` — search returns expected paths for known fixtures. +- `tests/e2e/test_agent_qa_e2e.py` — file a doc, ask a question, assert citation appears in the reply. Verification: ask 10 known-answer questions in a test room. ≥8 produce grounded replies with valid citations within latency target. @@ -173,9 +173,9 @@ Shape: Tests: -- `tests/stacklets/test_grocery_parser.py` — natural-language inputs produce expected tool calls. -- `tests/integration/test_grocery_e2e.py` — chat round-trip adds and removes items against the vault backend. -- `tests/stacklets/test_grocery_store_protocol.py` — both backends implement the same protocol contract. +- `tests/unit/stacklets/test_grocery_parser.py` — natural-language inputs produce expected tool calls. +- `tests/e2e/test_grocery_e2e.py` — chat round-trip adds and removes items against the vault backend. +- `tests/unit/stacklets/test_grocery_store_protocol.py` — both backends implement the same protocol contract. Verification: the family adds items across a week, asks for the list at the store, uses it, finishes the trip without abandoning the bot. @@ -192,7 +192,7 @@ Independent of the agent runtime. A tiny new mechanism, not bundled with the fra Tests: -- `tests/stacklets/test_reminders_due.py` — given a fixture file and a clock, the right entries fire. +- `tests/unit/stacklets/test_reminders_due.py` — given a fixture file and a clock, the right entries fire. Time: ~1 day. diff --git a/docs/design/brain/content-pattern.md b/docs/design/brain/content-pattern.md index 54472a4c..b4c181cc 100644 --- a/docs/design/brain/content-pattern.md +++ b/docs/design/brain/content-pattern.md @@ -260,7 +260,7 @@ before one replaces the other. | Reading step | `mode`, `spoken_date`, `addressee`, `gist`, `moments` | card extraction: the same, plus `title`, `description`, `persons`, `tags`, `summary`, `facts`; cached per entry | | Entry unit | message group, rendered by day | the unit under test (see above); the compiler takes it as a parameter | | Output | month pages (brain) | cards in a separate vault tree, card posts in a test room, month pages composed from the cards in a separate brain tree | -| Tests | `tests/stacklets/test_memory_diary.py` | new module tests, written first | +| Tests | `tests/unit/stacklets/test_memory_diary.py` | new module tests, written first | ### Comparison diff --git a/docs/design/brain/plan.md b/docs/design/brain/plan.md index 0df42e34..5dff259f 100644 --- a/docs/design/brain/plan.md +++ b/docs/design/brain/plan.md @@ -103,10 +103,10 @@ Files: Tests: -- `tests/stacklets/test_ontology_taxonomy_sync.py` — every name in `stacklets/docs/taxonomy.toml` has a matching id in `stacklets/memory/seeds/ontology.toml` (and vice versa). Fails loudly on drift. -- `tests/stacklets/test_ontology_loader.py` — load, resolve_topics, resolve_person, expand_query happy paths. -- `tests/stacklets/test_memory_install.py` — `on_install_success` creates the repo and pushes seeds against a real Forgejo from the test harness (per project rules: no library mocks). -- `tests/integration/test_archivist_e2e.py` — already exists; must stay green with the new ontology source. +- `tests/unit/stacklets/test_ontology_taxonomy_sync.py` — every name in `stacklets/docs/taxonomy.toml` has a matching id in `stacklets/memory/seeds/ontology.toml` (and vice versa). Fails loudly on drift. +- `tests/unit/stacklets/test_ontology_loader.py` — load, resolve_topics, resolve_person, expand_query happy paths. +- `tests/unit/stacklets/test_memory_install.py` — `on_install_success` creates the repo and pushes seeds against a real Forgejo from the test harness (per project rules: no library mocks). +- `tests/e2e/test_archivist_e2e.py` — already exists; must stay green with the new ontology source. Out of scope: @@ -126,8 +126,8 @@ Files: Tests: -- `tests/stacklets/test_facts_store.py` — read/write round-trip against a temp memory repo. -- `tests/stacklets/test_facts_cli.py` — `stack facts add` produces the expected commit in the working copy. +- `tests/unit/stacklets/test_facts_store.py` — read/write round-trip against a temp memory repo. +- `tests/unit/stacklets/test_facts_cli.py` — `stack facts add` produces the expected commit in the working copy. Time: ~5–7h. @@ -150,7 +150,7 @@ Files: Tests: -- `tests/stacklets/test_interview_seed.py` — mocked answers produce the expected facts and stubs in the memory repo. +- `tests/unit/stacklets/test_interview_seed.py` — mocked answers produce the expected facts and stubs in the memory repo. Time: ~4–6h. @@ -167,8 +167,8 @@ Files: Tests: -- `tests/stacklets/test_qa_handler.py` — routing heuristic + prompt assembly. -- `tests/integration/test_archivist_qa_e2e.py` — file a doc, ask a question, assert the citation appears in the reply. +- `tests/unit/stacklets/test_qa_handler.py` — routing heuristic + prompt assembly. +- `tests/e2e/test_archivist_qa_e2e.py` — file a doc, ask a question, assert the citation appears in the reply. Time: ~5–7h. @@ -184,7 +184,7 @@ Files: Tests: -- `tests/stacklets/test_wiki_rebuild.py` — given a corpus of mirrors, rebuild produces the expected pages. +- `tests/unit/stacklets/test_wiki_rebuild.py` — given a corpus of mirrors, rebuild produces the expected pages. - Idempotency: re-running on unchanged input produces byte-identical output (after timestamp normalization). Time: ~6–10h. diff --git a/docs/design/brain/topic-rooms.md b/docs/design/brain/topic-rooms.md index 4cfec838..2a3e9690 100644 --- a/docs/design/brain/topic-rooms.md +++ b/docs/design/brain/topic-rooms.md @@ -244,7 +244,7 @@ The mirror writer treats `topics:` as load-bearing and writes the merged list ve ### Test pins -A new test class `TestTopicSeedInvariant` in `tests/stacklets/test_capture_pipeline.py` pins all three: +A new test class `TestTopicSeedInvariant` in `tests/unit/stacklets/test_capture_pipeline.py` pins all three: ```python async def test_seed_survives_empty_classifier_output(): @@ -337,11 +337,11 @@ stack memory topic archive Manual archive (sets status, no M | `stacklets/docs/bot/search_service.py` | changed | `scopes_for_sender` and `run` accept `topic_bucket`. When set, scopes become `[/]` only; otherwise the existing shared + sender defaults. | | `stacklets/memory/cli/topic.py` | future | `stack memory topic list / show / rename / demote / archive`. Not in v1. | | `stacklets/memory/cli/wiki.py` | future change | Extend to discover topic folders under each bucket and generate `about.md` from captures + room state. Replaces the per-bootstrap scaffold idea. | -| `tests/stacklets/test_topic_rooms.py` | new | Parser, slug derivation, reserved-name check, scope detection, bucket composition, room-state shape, binding extraction. Pure unit tests. | -| `tests/stacklets/test_capture_pipeline.py` | changed | `TestTopicSeedMerge`, `TestTopicSeedEndToEnd` — the three-piece invariant plus the bucket-override pins. | -| `tests/stacklets/test_search_service.py` | changed | `TestScopesWithTopicBucket` — topic-bucket override semantics. | -| `tests/stacklets/test_archivist_topic_bootstrap.py` | new | Bootstrap flow, idempotency, reserved-slug refusal, resilience, human counting. Light fakes for the nio state I/O. | -| `tests/stacklets/test_topic_promotion.py` | future | Personal → shared promotion, debounce, collision, cancellation. Lands with Step 5. | +| `tests/unit/stacklets/test_topic_rooms.py` | new | Parser, slug derivation, reserved-name check, scope detection, bucket composition, room-state shape, binding extraction. Pure unit tests. | +| `tests/unit/stacklets/test_capture_pipeline.py` | changed | `TestTopicSeedMerge`, `TestTopicSeedEndToEnd` — the three-piece invariant plus the bucket-override pins. | +| `tests/unit/stacklets/test_search_service.py` | changed | `TestScopesWithTopicBucket` — topic-bucket override semantics. | +| `tests/unit/stacklets/test_archivist_topic_bootstrap.py` | new | Bootstrap flow, idempotency, reserved-slug refusal, resilience, human counting. Light fakes for the nio state I/O. | +| `tests/unit/stacklets/test_topic_promotion.py` | future | Personal → shared promotion, debounce, collision, cancellation. Lands with Step 5. | ### Sequencing diff --git a/docs/design/brain/who-answers.md b/docs/design/brain/who-answers.md index a967aba8..4d4c598b 100644 --- a/docs/design/brain/who-answers.md +++ b/docs/design/brain/who-answers.md @@ -253,14 +253,14 @@ envelope rather than the prose. ## Tests that state these rules -- `tests/stacklets/test_microbot.py::TestThreadOwner` - the ownership +- `tests/unit/stacklets/test_microbot.py::TestThreadOwner` - the ownership rule itself, including the mail bot's shape. -- `tests/stacklets/test_archivist_corrections.py::TestOnlyOurOwnThreads` - +- `tests/unit/stacklets/test_archivist_corrections.py::TestOnlyOurOwnThreads` - what the family sees: the agent's thread is left alone, an @-mention still lands, the main timeline is unchanged. -- `tests/stacklets/test_agent_thread_trigger.py` - the same contract from +- `tests/unit/stacklets/test_agent_thread_trigger.py` - the same contract from the agent's side. -- `tests/integration/test_bot_arbitration_e2e.py` - the whole contract +- `tests/e2e/test_bot_arbitration_e2e.py` - the whole contract against real containers: real Synapse relations, a real second bot in the thread, a real 📌. Marked `unverified` until a green rig run. diff --git a/docs/stack-reference.md b/docs/stack-reference.md index a91a74b5..909c46ea 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -389,7 +389,7 @@ main path with it. Subscribing to custom events on the bot side isn't wired yet — that comes with the first consumer bot. The emit contract is stable and -tested end-to-end (see `tests/integration/test_archivist_e2e.py`). +tested end-to-end (see `tests/e2e/test_archivist_e2e.py`). ### Backup @@ -1090,7 +1090,7 @@ state, data) is swappable. Point the CLI at a different instance with `STACK_DIR`: ``` -STACK_DIR=tests/integration/instance stack up docs +STACK_DIR=tests/e2e/instance stack up docs ``` The same `stacklets/` definitions apply, but config, secrets, setup diff --git a/docs/todos/refactor-cleanup-plan.md b/docs/todos/refactor-cleanup-plan.md index 0dab0969..edad3109 100644 --- a/docs/todos/refactor-cleanup-plan.md +++ b/docs/todos/refactor-cleanup-plan.md @@ -362,7 +362,7 @@ oMLX, and some backends serialize. backend to reject with a clean 4xx if the model can't see, and catch that to retry text-only. - Remove the `_seed_model_capabilities` shim in - `tests/integration/_seed_secrets.py` once the cache is gone. + `tests/e2e/_seed_secrets.py` once the cache is gone. **Why:** simpler bot (less state, fewer files), the test no longer needs a magic seed to work, and the race is gone by construction. diff --git a/lib/stack/doctor.py b/lib/stack/doctor.py index a3547c5b..5e26b254 100644 --- a/lib/stack/doctor.py +++ b/lib/stack/doctor.py @@ -207,7 +207,7 @@ def check_endpoint(name: str, url: str, reachable: bool) -> Finding | None: level=WARN, title=f"{name} endpoint is not answering", detail=f"Configured as {url}, but it did not respond.", - fix="tests/integration/stacktests ai # check or switch the backend", + fix="tests/e2e/stacktests ai # check or switch the backend", ) diff --git a/pyproject.toml b/pyproject.toml index 4e4fc2b7..e8067b37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,8 +51,8 @@ demo = [ [tool.pytest.ini_options] asyncio_mode = "auto" # Pipeline-quality evals (real model, real Paperless, slow) are opt-in via -# `tests/integration/stacktests eval` — never collected by `pytest tests/`. -norecursedirs = ["tests/integration/eval"] +# `tests/e2e/stacktests eval` — never collected by `pytest tests/`. +norecursedirs = ["tests/e2e/eval"] markers = [ "container_lifecycle: Docker lifecycle tests for stack up/down/destroy and rendered container environments", "demo_rig: live demo-instance tests using Simpson accounts and the configured AI endpoint", diff --git a/script/test b/script/test index 5e5a2098..d381f739 100755 --- a/script/test +++ b/script/test @@ -12,13 +12,8 @@ cd "$(dirname "$0")/.." PYTEST=(uv run --extra test pytest) -# Kept out of the unit lane because they drive the host, not Python: -# Docker for the one, `hdiutil` mounting an APFS image per test for the -# other, which alone was a third of the unit lane's time. -SLOW_FILES=( - tests/framework/test_config_to_container.py - tests/stacklets/test_backup_e2e.py -) +# Each lane is a directory: tests/unit, tests/integration, and tests/e2e +# behind stacktests. A test's place in the tree says what it costs. lanes() { cat <<'EOF' @@ -71,8 +66,7 @@ case "$lane" in # when a piece of work is finished, too slow to run on every commit. unit) script/setup --hooks-only - "${PYTEST[@]}" tests/framework tests/stacklets -v \ - "${SLOW_FILES[@]/#/--ignore=}" "$@" + "${PYTEST[@]}" tests/unit -v "$@" ;; # The heavier profiles, running real things on the host: the Docker @@ -80,21 +74,21 @@ case "$lane" in # on its own throwaway instance with fixed names and ports, and the # backup engine against real APFS disk images, one mounted per test. integration) - "${PYTEST[@]}" "${SLOW_FILES[@]}" -v "$@" + "${PYTEST[@]}" tests/integration -v "$@" ;; # Against the demo instance already running in this checkout. Tests create # unique data and clean up after themselves; they never reset the instance. demo) - tests/integration/stacktests demo-rig "$@" + tests/e2e/stacktests demo-rig "$@" ;; # The managed rig. On the Simpsons demo, stacktests stashes it for the run. smoke) - tests/integration/stacktests smoke "$@" + tests/e2e/stacktests smoke "$@" ;; e2e) - tests/integration/stacktests e2e "$@" + tests/e2e/stacktests e2e "$@" ;; *) diff --git a/stacklets/agent/runtime/sitecustomize.py b/stacklets/agent/runtime/sitecustomize.py index 252ffe8f..2539c7dd 100644 --- a/stacklets/agent/runtime/sitecustomize.py +++ b/stacklets/agent/runtime/sitecustomize.py @@ -85,7 +85,7 @@ join_greeting: `nanobot.channels.matrix.MatrixChannel._on_room_invite(self, room, event)` `MatrixChannel._handle_message(sender_id, chat_id, content, metadata, is_dm)` - `tests/stacklets/test_agent_runtime_shims.py` asserts every one of these is + `tests/unit/stacklets/test_agent_runtime_shims.py` asserts every one of these is attached — but against a *stub* nanobot this repo hand-writes, so read what that does and does not buy. It catches our own mistakes: a shim that stops attaching, or one whose failure takes another down with it. It cannot catch diff --git a/stacklets/backup/engines/external-disk/README.md b/stacklets/backup/engines/external-disk/README.md index 2763d853..b25dfde6 100644 --- a/stacklets/backup/engines/external-disk/README.md +++ b/stacklets/backup/engines/external-disk/README.md @@ -143,10 +143,10 @@ can't interleave or truncate a record mid-line. ## Testing -`tests/stacklets/test_backup_engine.py` covers the pure-Python parts: +`tests/unit/stacklets/test_backup_engine.py` covers the pure-Python parts: source parsing, canary creation and tampering, preflight thresholds, filesystem capability classification, result-file shape. The rsync/diskutil/eject flows need a real disk and aren't part of the unit suite. -Run with: `uv run --extra test pytest tests/stacklets/test_backup_engine.py` +Run with: `uv run --extra test pytest tests/unit/stacklets/test_backup_engine.py` diff --git a/stacklets/docs/bot/cli/capture.py b/stacklets/docs/bot/cli/capture.py index 0b6102ae..78e09697 100644 --- a/stacklets/docs/bot/cli/capture.py +++ b/stacklets/docs/bot/cli/capture.py @@ -39,7 +39,7 @@ The agent relays this output to the family. A receipt that reads the same whether or not anything was filed is precisely what lets an agent report a success it never had, so a failure here never renders - as a filing. See tests/stacklets/test_memory_capture_cli.py. + as a filing. See tests/unit/stacklets/test_memory_capture_cli.py. """ from __future__ import annotations diff --git a/stacklets/memory/bot/cli/todo_list.py b/stacklets/memory/bot/cli/todo_list.py index 28993b23..452f0003 100644 --- a/stacklets/memory/bot/cli/todo_list.py +++ b/stacklets/memory/bot/cli/todo_list.py @@ -10,7 +10,7 @@ announces it ("Liste Bus Erweiterungen:", "Todo:", "Einkaufsliste:"). We deliberately do NOT mine arbitrary notes: the capture classifier leaves action_items out on purpose, because a pasted Reddit thread must not manufacture -a household todo (see tests/stacklets/test_capture_prompt.py). So detection here +a household todo (see tests/unit/stacklets/test_capture_prompt.py). So detection here is a narrow, deterministic signal — an explicit marker — not an LLM guess. High precision, zero false positives, no model call. diff --git a/stacklets/memory/bot/curator.py b/stacklets/memory/bot/curator.py index 088d0b6c..86da9ffc 100644 --- a/stacklets/memory/bot/curator.py +++ b/stacklets/memory/bot/curator.py @@ -119,7 +119,7 @@ CURATOR_REMOTE = "curator" -# ── Pure decision logic (tested in tests/stacklets) ────────────────────── +# ── Pure decision logic (tested in tests/unit/stacklets) ────────────────────── def only_own_commits(subjects: list[str]) -> bool: """True when every commit subject is a wiki publish of our own. diff --git a/stacklets/memory/cli/check.py b/stacklets/memory/cli/check.py index e7e006b5..9e798430 100644 --- a/stacklets/memory/cli/check.py +++ b/stacklets/memory/cli/check.py @@ -15,7 +15,7 @@ Exits 1 with a list of drifted names when anything is missing. This is the pre-install / CI-friendly sync gate. The same logic backs -`tests/stacklets/test_ontology_taxonomy_sync.py`, which invokes this +`tests/unit/stacklets/test_ontology_taxonomy_sync.py`, which invokes this command as a subprocess. """ diff --git a/stacklets/memory/seeds/ontology.toml b/stacklets/memory/seeds/ontology.toml index 7cb115e1..a145a012 100644 --- a/stacklets/memory/seeds/ontology.toml +++ b/stacklets/memory/seeds/ontology.toml @@ -15,7 +15,7 @@ # synonyms = { = ["..."] } # optional # # Every entry here must mirror a name in stacklets/docs/taxonomy.toml. -# tests/stacklets/test_ontology_taxonomy_sync.py enforces no-drift in +# tests/unit/stacklets/test_ontology_taxonomy_sync.py enforces no-drift in # both directions. # ────────────────────────────────────────────────────────────────────── diff --git a/tests/README.md b/tests/README.md index d494ba79..930b00fd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -55,27 +55,35 @@ destructive test-rig commands still require care. ## Structure -`tests/framework/` contains framework-level tests. Most are offline unit tests. -`tests/framework/test_config_to_container.py` is intentionally separate because -it talks to Docker and dominates runtime. `tests/stacklets/test_backup_e2e.py` -joins it in the integration lane: it creates and mounts an APFS disk image per -test, about a second each, which was a third of the unit lane. The list lives -in `script/test` as `SLOW_FILES`. - -`tests/stacklets/` contains stacklet-level unit tests. These should exercise -stacklet modules through public boundaries with local fixtures, fake HTTP -servers, temporary repositories, or mocked external services. They belong in -`unit` unless they require real containers. - -`tests/integration/test_demo_rig_e2e.py` contains live demo-rig tests. These +Each lane is a directory, so where a test lives says what it costs. + +``` +tests/unit/framework/ unit offline, no Docker, no live services +tests/unit/stacklets/ unit +tests/integration/ integration real things on the host +tests/e2e/ demo, smoke, e2e, eval, all through stacktests +``` + +`tests/unit/framework/` holds framework tests and `tests/unit/stacklets/` +stacklet tests. The latter exercise stacklet modules through public +boundaries with local fixtures, fake HTTP servers, temporary repositories, or +mocked external services. A test that needs real containers or other host +resources does not belong under `unit/`. + +`tests/integration/` holds the tests that drive the host rather than Python: +`test_config_to_container.py` runs the Docker lifecycle on a throwaway +instance, and `test_backup_e2e.py` mounts an APFS disk image per test, about a +second each. + +`tests/e2e/test_demo_rig_e2e.py` contains live demo-rig tests. These run against the operator's current demo instance and must not reset or own the instance. Use this lane when you need read-your-writes confidence against the running bots and real service wiring. -`tests/integration/test_*_e2e.py` contains managed container e2e tests. These -run through `tests/integration/stacktests`, which seeds a test-owned instance, +`tests/e2e/test_*_e2e.py` contains managed container e2e tests. These +run through `tests/e2e/stacktests`, which seeds a test-owned instance, starts required stacklets, and keeps the rig reusable between runs. -`tests/integration/eval/` is opt-in prompt and model evaluation. It is excluded -from normal pytest collection and is run with `tests/integration/stacktests eval`. +`tests/e2e/eval/` is opt-in prompt and model evaluation. It is excluded +from normal pytest collection and is run with `tests/e2e/stacktests eval`. diff --git a/tests/framework/__init__.py b/tests/e2e/__init__.py similarity index 100% rename from tests/framework/__init__.py rename to tests/e2e/__init__.py diff --git a/tests/integration/_ai_mode.py b/tests/e2e/_ai_mode.py similarity index 98% rename from tests/integration/_ai_mode.py rename to tests/e2e/_ai_mode.py index 38387718..525904ee 100644 --- a/tests/integration/_ai_mode.py +++ b/tests/e2e/_ai_mode.py @@ -73,7 +73,7 @@ def _env(name: str, mode: str) -> str: f"{mode} mode needs {name}.\n\n" f" export {name}=...\n\n" "Endpoints are read from the environment, not committed - see\n" - "tests/integration/_ai_mode.py for why and for the full list." + "tests/e2e/_ai_mode.py for why and for the full list." ) return value @@ -164,7 +164,7 @@ def main(argv: list[str]) -> int: print(f" {key:<12} {_redact(key, value)}") if mode != "mock": print("\n Restart affected stacklets so containers pick this up:") - print(" tests/integration/stacktests up docs") + print(" tests/e2e/stacktests up docs") return 0 diff --git a/tests/integration/_check_versions.py b/tests/e2e/_check_versions.py similarity index 100% rename from tests/integration/_check_versions.py rename to tests/e2e/_check_versions.py diff --git a/tests/integration/_seed_secrets.py b/tests/e2e/_seed_secrets.py similarity index 96% rename from tests/integration/_seed_secrets.py rename to tests/e2e/_seed_secrets.py index d9cc2f3c..24cdd04c 100644 --- a/tests/integration/_seed_secrets.py +++ b/tests/e2e/_seed_secrets.py @@ -9,7 +9,7 @@ marker, any pre-existing `stack.toml` or `.stack/` means someone's actual famstack lives here and we bail out with a cleanup hint. - 2. Otherwise copies the test templates (`tests/integration/instance/ + 2. Otherwise copies the test templates (`tests/e2e/instance/ stack.toml` + `users.toml`) into the repo root, writes the sentinel, and seeds the global secrets (admin + user passwords) that `stack init` normally creates interactively. @@ -72,7 +72,7 @@ def _conflict_message() -> str: "If this is your actual famstack, take it down first:\n\n" " stack down all && stack destroy all --yes\n" " rm -f stack.toml users.toml && rm -rf .stack ~/famstack-data\n\n" - "Or use `tests/integration/stacktests cleanup` to reset a\n" + "Or use `tests/e2e/stacktests cleanup` to reset a\n" "previously-test-owned repo root." ) @@ -104,7 +104,7 @@ def seed() -> None: shutil.copy(TEMPLATE_DIR / "users.toml", USERS_TOML) if not SENTINEL.exists(): SENTINEL.write_text( - "# Written by tests/integration/_seed_secrets.py.\n" + "# Written by tests/e2e/_seed_secrets.py.\n" "# Marks this repo root as an active test instance — the\n" "# rig overwrites stack.toml, users.toml, and .stack/ here.\n" "# Remove this file (plus the configs) to reclaim the repo\n" diff --git a/tests/integration/bdd.py b/tests/e2e/bdd.py similarity index 100% rename from tests/integration/bdd.py rename to tests/e2e/bdd.py diff --git a/tests/integration/conftest.py b/tests/e2e/conftest.py similarity index 96% rename from tests/integration/conftest.py rename to tests/e2e/conftest.py index d45ff234..68abd878 100644 --- a/tests/integration/conftest.py +++ b/tests/e2e/conftest.py @@ -10,7 +10,7 @@ Stacklets are spun up on demand by the fixtures below — `paperless` brings up `docs`, `matrix` brings up `messages`, `code` brings up Forgejo. They stay running across pytest invocations; tear them down -between coding sessions with `tests/integration/stacktests cleanup`. +between coding sessions with `tests/e2e/stacktests cleanup`. Per-test isolation is by prefix: every entity a test creates in a backend carries its scope uid, and teardown deletes only what matches. @@ -41,11 +41,11 @@ sys.path.insert(0, str(REPO_ROOT / "lib")) -from tests.integration.paperless import PaperlessAPI, cleanup_prefix -from tests.integration.matrix import login -from tests.integration.forgejo import ForgejoAPI, cleanup_mirror_files -from tests.integration.bdd import BDDLog -from tests.integration._seed_secrets import ( +from tests.e2e.paperless import PaperlessAPI, cleanup_prefix +from tests.e2e.matrix import login +from tests.e2e.forgejo import ForgejoAPI, cleanup_mirror_files +from tests.e2e.bdd import BDDLog +from tests.e2e._seed_secrets import ( seed as _seed_test_instance_secrets, TestInstanceConflict, ) @@ -56,7 +56,7 @@ @pytest.fixture(scope="session") def httpserver_listen_address(): """pytest-httpserver binds to 127.0.0.1:42199 — the `openai_url` in - tests/integration/instance/stack.toml points here.""" + tests/e2e/instance/stack.toml points here.""" return ("127.0.0.1", 42199) @@ -77,7 +77,7 @@ def openai(httpserver): Polling helpers should call `openai.raise_if_unexpected()` per tick to turn a wrong call count into an instant, named failure. """ - from tests.integration.openai_stub import OpenAIStub + from tests.e2e.openai_stub import OpenAIStub stub = OpenAIStub(httpserver) yield stub @@ -210,7 +210,7 @@ def paperless(test_stack) -> PaperlessAPI: First call per coding session: ~30s (container boot + Celery warmup). Subsequent: no-op. Not torn down at session end — stop the test stack - manually with tests/integration/test-env-down.sh. + manually with tests/e2e/test-env-down.sh. """ try: _seed_test_instance_secrets() @@ -251,7 +251,7 @@ def matrix(test_stack) -> dict: Returns a dict of MatrixCreds keyed by username. Session-scoped so the first test pays the ~40s Synapse boot and the rest pay nothing. - Not torn down — stop with tests/integration/test-env-down.sh. + Not torn down — stop with tests/e2e/test-env-down.sh. """ try: _seed_test_instance_secrets() diff --git a/tests/integration/eval/__init__.py b/tests/e2e/eval/__init__.py similarity index 89% rename from tests/integration/eval/__init__.py rename to tests/e2e/eval/__init__.py index 279d0982..36cd7359 100644 --- a/tests/integration/eval/__init__.py +++ b/tests/e2e/eval/__init__.py @@ -4,5 +4,5 @@ `cases/`. Report-only: per-field scorecards print to stdout, the pytest run always passes. Excluded from default pytest collection via `pyproject.toml`'s `norecursedirs`; the only entrypoint is -`tests/integration/stacktests eval`. +`tests/e2e/stacktests eval`. """ diff --git a/tests/integration/eval/_artifacts.py b/tests/e2e/eval/_artifacts.py similarity index 100% rename from tests/integration/eval/_artifacts.py rename to tests/e2e/eval/_artifacts.py diff --git a/tests/integration/eval/cases/grundriss-flaechen.png b/tests/e2e/eval/cases/grundriss-flaechen.png similarity index 100% rename from tests/integration/eval/cases/grundriss-flaechen.png rename to tests/e2e/eval/cases/grundriss-flaechen.png diff --git a/tests/integration/eval/cases/grundriss-flaechen.yaml b/tests/e2e/eval/cases/grundriss-flaechen.yaml similarity index 100% rename from tests/integration/eval/cases/grundriss-flaechen.yaml rename to tests/e2e/eval/cases/grundriss-flaechen.yaml diff --git a/tests/integration/eval/cases/kassenbon-kaeserei.pdf b/tests/e2e/eval/cases/kassenbon-kaeserei.pdf similarity index 100% rename from tests/integration/eval/cases/kassenbon-kaeserei.pdf rename to tests/e2e/eval/cases/kassenbon-kaeserei.pdf diff --git a/tests/integration/eval/cases/kassenbon-kaeserei.yaml b/tests/e2e/eval/cases/kassenbon-kaeserei.yaml similarity index 100% rename from tests/integration/eval/cases/kassenbon-kaeserei.yaml rename to tests/e2e/eval/cases/kassenbon-kaeserei.yaml diff --git a/tests/integration/eval/cases/kassenbon-obst.pdf b/tests/e2e/eval/cases/kassenbon-obst.pdf similarity index 100% rename from tests/integration/eval/cases/kassenbon-obst.pdf rename to tests/e2e/eval/cases/kassenbon-obst.pdf diff --git a/tests/integration/eval/cases/kassenbon-obst.yaml b/tests/e2e/eval/cases/kassenbon-obst.yaml similarity index 100% rename from tests/integration/eval/cases/kassenbon-obst.yaml rename to tests/e2e/eval/cases/kassenbon-obst.yaml diff --git a/tests/integration/eval/cases/rechnung.png b/tests/e2e/eval/cases/rechnung.png similarity index 100% rename from tests/integration/eval/cases/rechnung.png rename to tests/e2e/eval/cases/rechnung.png diff --git a/tests/integration/eval/cases/rechnung.yaml b/tests/e2e/eval/cases/rechnung.yaml similarity index 100% rename from tests/integration/eval/cases/rechnung.yaml rename to tests/e2e/eval/cases/rechnung.yaml diff --git a/tests/integration/eval/cases/rezept-fisch.pdf b/tests/e2e/eval/cases/rezept-fisch.pdf similarity index 100% rename from tests/integration/eval/cases/rezept-fisch.pdf rename to tests/e2e/eval/cases/rezept-fisch.pdf diff --git a/tests/integration/eval/cases/rezept-fisch.yaml b/tests/e2e/eval/cases/rezept-fisch.yaml similarity index 100% rename from tests/integration/eval/cases/rezept-fisch.yaml rename to tests/e2e/eval/cases/rezept-fisch.yaml diff --git a/tests/integration/eval/cases/rezept-kuchen.pdf b/tests/e2e/eval/cases/rezept-kuchen.pdf similarity index 100% rename from tests/integration/eval/cases/rezept-kuchen.pdf rename to tests/e2e/eval/cases/rezept-kuchen.pdf diff --git a/tests/integration/eval/cases/rezept-kuchen.yaml b/tests/e2e/eval/cases/rezept-kuchen.yaml similarity index 100% rename from tests/integration/eval/cases/rezept-kuchen.yaml rename to tests/e2e/eval/cases/rezept-kuchen.yaml diff --git a/tests/integration/eval/cases/rezept-lasagne.pdf b/tests/e2e/eval/cases/rezept-lasagne.pdf similarity index 100% rename from tests/integration/eval/cases/rezept-lasagne.pdf rename to tests/e2e/eval/cases/rezept-lasagne.pdf diff --git a/tests/integration/eval/cases/rezept-lasagne.yaml b/tests/e2e/eval/cases/rezept-lasagne.yaml similarity index 100% rename from tests/integration/eval/cases/rezept-lasagne.yaml rename to tests/e2e/eval/cases/rezept-lasagne.yaml diff --git a/tests/integration/eval/cases/tankbeleg-esso.png b/tests/e2e/eval/cases/tankbeleg-esso.png similarity index 100% rename from tests/integration/eval/cases/tankbeleg-esso.png rename to tests/e2e/eval/cases/tankbeleg-esso.png diff --git a/tests/integration/eval/cases/tankbeleg-esso.yaml b/tests/e2e/eval/cases/tankbeleg-esso.yaml similarity index 100% rename from tests/integration/eval/cases/tankbeleg-esso.yaml rename to tests/e2e/eval/cases/tankbeleg-esso.yaml diff --git a/tests/integration/eval/conftest.py b/tests/e2e/eval/conftest.py similarity index 98% rename from tests/integration/eval/conftest.py rename to tests/e2e/eval/conftest.py index 51445898..20ea4ac1 100644 --- a/tests/integration/eval/conftest.py +++ b/tests/e2e/eval/conftest.py @@ -1,6 +1,6 @@ """Eval-only fixtures — real classifier wired to a live AI stacklet. -Lives one level deeper than `tests/integration/conftest.py` so it +Lives one level deeper than `tests/e2e/conftest.py` so it inherits all the test-rig plumbing (paperless, paperless_scope, scope, bdd) and only adds what the eval needs: @@ -38,7 +38,7 @@ if str(_BOT_DIR) not in sys.path: sys.path.insert(0, str(_BOT_DIR)) -from tests.integration.eval._artifacts import RunArtifacts +from tests.e2e.eval._artifacts import RunArtifacts def _keep_docs() -> bool: @@ -230,7 +230,7 @@ def paperless_scope(paperless, scope): # noqa: F811 — intentional override titles, so a later `cleanup` sweep (or the next test session) wipes them. """ - from tests.integration.paperless import cleanup_prefix + from tests.e2e.paperless import cleanup_prefix if _keep_docs(): print(f"\n EVAL_KEEP_DOCS=1 — uploaded docs will survive " diff --git a/tests/integration/eval/scoring.py b/tests/e2e/eval/scoring.py similarity index 100% rename from tests/integration/eval/scoring.py rename to tests/e2e/eval/scoring.py diff --git a/tests/integration/eval/test_classify_eval.py b/tests/e2e/eval/test_classify_eval.py similarity index 99% rename from tests/integration/eval/test_classify_eval.py rename to tests/e2e/eval/test_classify_eval.py index 10fcce82..6121ad58 100644 --- a/tests/integration/eval/test_classify_eval.py +++ b/tests/e2e/eval/test_classify_eval.py @@ -23,7 +23,7 @@ import pytest import yaml -from tests.integration.eval.scoring import Scorecard, score_classification +from tests.e2e.eval.scoring import Scorecard, score_classification CASES_DIR = Path(__file__).parent / "cases" diff --git a/tests/integration/forgejo.py b/tests/e2e/forgejo.py similarity index 100% rename from tests/integration/forgejo.py rename to tests/e2e/forgejo.py diff --git a/tests/integration/instance/.gitignore b/tests/e2e/instance/.gitignore similarity index 100% rename from tests/integration/instance/.gitignore rename to tests/e2e/instance/.gitignore diff --git a/tests/integration/instance/stack.toml b/tests/e2e/instance/stack.toml similarity index 93% rename from tests/integration/instance/stack.toml rename to tests/e2e/instance/stack.toml index 08a6fb6e..5f4a9c51 100644 --- a/tests/integration/instance/stack.toml +++ b/tests/e2e/instance/stack.toml @@ -20,7 +20,7 @@ shared_bucket = "family" schedule = "0 0 3 * * *" [ai] -# AI backend. Flip with `tests/integration/stacktests ai [mock|local|external]`. +# AI backend. Flip with `tests/e2e/stacktests ai [mock|local|external]`. # mock points at the pytest-httpserver stub the `openai` fixture starts; # local/external endpoints come from the environment, never committed. openai_url = "http://localhost:42199/v1" diff --git a/tests/integration/instance/users.toml b/tests/e2e/instance/users.toml similarity index 100% rename from tests/integration/instance/users.toml rename to tests/e2e/instance/users.toml diff --git a/tests/integration/matrix.py b/tests/e2e/matrix.py similarity index 99% rename from tests/integration/matrix.py rename to tests/e2e/matrix.py index 779f521b..653db240 100644 --- a/tests/integration/matrix.py +++ b/tests/e2e/matrix.py @@ -37,7 +37,7 @@ def realm() -> str: if not stack_toml.exists(): raise RuntimeError( f"{stack_toml} is missing — bring the instance up first " - "(tests/integration/stacktests up), which seeds it." + "(tests/e2e/stacktests up), which seeds it." ) with stack_toml.open("rb") as fh: name = tomllib.load(fh).get("messages", {}).get("server_name") diff --git a/tests/integration/openai_stub.py b/tests/e2e/openai_stub.py similarity index 100% rename from tests/integration/openai_stub.py rename to tests/e2e/openai_stub.py diff --git a/tests/integration/paperless.py b/tests/e2e/paperless.py similarity index 100% rename from tests/integration/paperless.py rename to tests/e2e/paperless.py diff --git a/tests/integration/stacktests b/tests/e2e/stacktests similarity index 91% rename from tests/integration/stacktests rename to tests/e2e/stacktests index ec5bc3a4..2cd084a1 100755 --- a/tests/integration/stacktests +++ b/tests/e2e/stacktests @@ -8,30 +8,30 @@ # overwriting it. # # Usage (callable from anywhere — paths resolve off the script location): -# tests/integration/stacktests help — this text, plus autonomy -# tests/integration/stacktests preflight — offline gate: versions, +# tests/e2e/stacktests help — this text, plus autonomy +# tests/e2e/stacktests preflight — offline gate: versions, # lock, ruff, unit tests -# tests/integration/stacktests unverified — list parked intent-specs -# tests/integration/stacktests ai [mode] — show/flip the AI backend: +# tests/e2e/stacktests unverified — list parked intent-specs +# tests/e2e/stacktests ai [mode] — show/flip the AI backend: # mock | local | external -# tests/integration/stacktests up — bring up what tests need -# tests/integration/stacktests down — stop every test stacklet -# tests/integration/stacktests reset [slet] — destroy + up (clean slate) -# tests/integration/stacktests cleanup — nuke every test stacklet + +# tests/e2e/stacktests up — bring up what tests need +# tests/e2e/stacktests down — stop every test stacklet +# tests/e2e/stacktests reset [slet] — destroy + up (clean slate) +# tests/e2e/stacktests cleanup — nuke every test stacklet + # instance state + test data -# tests/integration/stacktests stash — set the dev instance aside +# tests/e2e/stacktests stash — set the dev instance aside # and swap the test rig in -# tests/integration/stacktests unstash — swap the rig out and bring +# tests/e2e/stacktests unstash — swap the rig out and bring # the dev instance back up -# tests/integration/stacktests e2e [args] — run every e2e test -# tests/integration/stacktests container-e2e — alias for e2e -# tests/integration/stacktests smoke [args] — run smoke e2e tests -# tests/integration/stacktests demo-rig [args] — run live Simpson demo tests -# tests/integration/stacktests demo [args] — alias for demo-rig -# tests/integration/stacktests eval [args] — pipeline quality eval +# tests/e2e/stacktests e2e [args] — run every e2e test +# tests/e2e/stacktests container-e2e — alias for e2e +# tests/e2e/stacktests smoke [args] — run smoke e2e tests +# tests/e2e/stacktests demo-rig [args] — run live Simpson demo tests +# tests/e2e/stacktests demo [args] — alias for demo-rig +# tests/e2e/stacktests eval [args] — pipeline quality eval # (real model, report-only) -# tests/integration/stacktests pytest [args] — pytest with env + deps -# tests/integration/stacktests — forward: logs, status... +# tests/e2e/stacktests pytest [args] — pytest with env + deps +# tests/e2e/stacktests — forward: logs, status... set -euo pipefail @@ -274,7 +274,7 @@ case "${1:-}" in ;; e2e|container-e2e) - # Run every end-to-end test under tests/integration/. Convention: + # Run every end-to-end test under tests/e2e/. Convention: # any file named test_*_e2e.py is picked up. Extra args forward to # pytest (e.g. `stacktests e2e -s -k homer`). # @@ -291,7 +291,7 @@ case "${1:-}" in for sid in "${REQUIRED_STACKLETS[@]}"; do stack_cli up "$sid" > /dev/null done - uv run --extra test pytest tests/integration/ -k "_e2e" -m "not demo_rig" --durations=20 "$@" + uv run --extra test pytest tests/e2e/ -k "_e2e" -m "not demo_rig" --durations=20 "$@" ;; smoke) @@ -303,7 +303,7 @@ case "${1:-}" in for sid in "${REQUIRED_STACKLETS[@]}"; do stack_cli up "$sid" > /dev/null done - uv run --extra test pytest tests/integration/ -k "_e2e" -m smoke "$@" + uv run --extra test pytest tests/e2e/ -k "_e2e" -m smoke "$@" ;; demo-rig|demo) @@ -313,7 +313,7 @@ case "${1:-}" in # data because tests create uniquely-named rooms/files and clean up # their own Paperless/Forgejo artifacts where the services allow it. shift || true - exec uv run --extra test pytest tests/integration/ -m demo_rig --durations=10 "$@" + exec uv run --extra test pytest tests/e2e/ -m demo_rig --durations=10 "$@" ;; eval) @@ -334,7 +334,7 @@ case "${1:-}" in # after the run for inspection # # Per-run artifacts (OCR text, classification JSON, scorecard) are - # always written to tests/integration/eval/runs/_/. + # always written to tests/e2e/eval/runs/_/. # # Pre-flight: requires `ai` to be up — fails fast with a clear # message rather than auto-installing models on someone's machine. @@ -348,7 +348,7 @@ case "${1:-}" in for sid in "${REQUIRED_STACKLETS[@]}"; do stack_cli up "$sid" > /dev/null done - exec uv run --extra test pytest -s -v tests/integration/eval/ "$@" + exec uv run --extra test pytest -s -v tests/e2e/eval/ "$@" ;; cleanup) diff --git a/tests/integration/test_archivist_e2e.py b/tests/e2e/test_archivist_e2e.py similarity index 99% rename from tests/integration/test_archivist_e2e.py rename to tests/e2e/test_archivist_e2e.py index ae487a58..52cdb716 100644 --- a/tests/integration/test_archivist_e2e.py +++ b/tests/e2e/test_archivist_e2e.py @@ -6,7 +6,7 @@ The test narrates itself via the `bdd` fixture so the stderr output reads like a protocol. Run with `-s` to stream live: - uv run --extra test pytest -s tests/integration/test_archivist_e2e.py + uv run --extra test pytest -s tests/e2e/test_archivist_e2e.py """ from __future__ import annotations @@ -18,7 +18,7 @@ from nio.api import RoomVisibility from nio.responses import JoinedMembersResponse, RoomInviteResponse -from tests.integration.matrix import ( +from tests.e2e.matrix import ( mxid, room_alias, ensure_joined, @@ -28,7 +28,7 @@ upload_and_send_file, wait_for_room_event, ) -from tests.integration.openai_stub import stub_classify, stub_reformat +from tests.e2e.openai_stub import stub_classify, stub_reformat DOCS_ROOM_ALIAS = room_alias("documents") diff --git a/tests/integration/test_archivist_paperless_api_e2e.py b/tests/e2e/test_archivist_paperless_api_e2e.py similarity index 98% rename from tests/integration/test_archivist_paperless_api_e2e.py rename to tests/e2e/test_archivist_paperless_api_e2e.py index 44e5cca7..725dd491 100644 --- a/tests/integration/test_archivist_paperless_api_e2e.py +++ b/tests/e2e/test_archivist_paperless_api_e2e.py @@ -18,7 +18,7 @@ class the bot uses (``stacklets/docs/bot/pipeline.PaperlessAPI``), and So none of the payloads below are written by us. They are whatever the running container returns. If upstream changes shape again, these tests -go red first and the unit fixtures in ``tests/stacklets/test_pipeline.py`` +go red first and the unit fixtures in ``tests/unit/stacklets/test_pipeline.py`` get corrected from what is asserted here. """ @@ -46,7 +46,7 @@ async def bot_api(paperless): """The archivist's own Paperless client, pointed at the test rig. Deliberately the production class rather than the test client in - ``tests/integration/paperless.py``: the question these tests answer + ``tests/e2e/paperless.py``: the question these tests answer is whether *the bot's* parsing survives 3.x, so the bot's parser has to be the thing under test. """ @@ -223,7 +223,7 @@ async def test_a_document_the_bot_uploaded_is_visible_to_the_bot( class TestTaskApiShape: """Prove the unit fixtures describe this server, not our memory of it. - ``tests/stacklets/test_pipeline.py`` parses task payloads offline, so + ``tests/unit/stacklets/test_pipeline.py`` parses task payloads offline, so it can only ever prove the parser agrees with the fixture. These two tests are the link back to reality: they hand the *real* payload to the *real* parser. If upstream reshapes the tasks API again, these diff --git a/tests/integration/test_bot_arbitration_e2e.py b/tests/e2e/test_bot_arbitration_e2e.py similarity index 99% rename from tests/integration/test_bot_arbitration_e2e.py rename to tests/e2e/test_bot_arbitration_e2e.py index 92380497..ed775070 100644 --- a/tests/integration/test_bot_arbitration_e2e.py +++ b/tests/e2e/test_bot_arbitration_e2e.py @@ -51,7 +51,7 @@ from nio import AsyncClient -from tests.integration.matrix import ( +from tests.e2e.matrix import ( event_type, fetch_room_events, mxid, diff --git a/tests/integration/test_capture_binary_e2e.py b/tests/e2e/test_capture_binary_e2e.py similarity index 99% rename from tests/integration/test_capture_binary_e2e.py rename to tests/e2e/test_capture_binary_e2e.py index 6d2b5067..c72f91ee 100644 --- a/tests/integration/test_capture_binary_e2e.py +++ b/tests/e2e/test_capture_binary_e2e.py @@ -21,12 +21,12 @@ Run with `-s` to stream the BDD narration: - uv run --extra test pytest -s tests/integration/test_capture_binary_e2e.py + uv run --extra test pytest -s tests/e2e/test_capture_binary_e2e.py """ from __future__ import annotations -from tests.integration.matrix import mxid +from tests.e2e.matrix import mxid import asyncio import io @@ -34,8 +34,8 @@ from nio.api import RoomVisibility from nio.responses import RoomInviteResponse, UploadResponse -from tests.integration.forgejo import ForgejoError -from tests.integration.openai_stub import stub_classify, stub_reformat +from tests.e2e.forgejo import ForgejoError +from tests.e2e.openai_stub import stub_classify, stub_reformat MEMORY_OWNER = "family" diff --git a/tests/integration/test_capture_memory_e2e.py b/tests/e2e/test_capture_memory_e2e.py similarity index 96% rename from tests/integration/test_capture_memory_e2e.py rename to tests/e2e/test_capture_memory_e2e.py index fa678cf2..04837b33 100644 --- a/tests/integration/test_capture_memory_e2e.py +++ b/tests/e2e/test_capture_memory_e2e.py @@ -12,19 +12,19 @@ Run with `-s` to stream the BDD narration live: - uv run --extra test pytest -s tests/integration/test_capture_memory_e2e.py + uv run --extra test pytest -s tests/e2e/test_capture_memory_e2e.py """ from __future__ import annotations -from tests.integration.matrix import mxid +from tests.e2e.matrix import mxid import asyncio import pytest -from tests.integration.forgejo import ForgejoError -from tests.integration.openai_stub import stub_classify +from tests.e2e.forgejo import ForgejoError +from tests.e2e.openai_stub import stub_classify # Same org/repo as the document mirror — captures and docs share the @@ -34,7 +34,7 @@ MEMORY_REPO = "memory" # Matrix ID the bot-runner registers the archivist under in the test -# instance (server name comes from `tests/integration/instance/stack.toml`). +# instance (server name comes from `tests/e2e/instance/stack.toml`). ARCHIVIST_MXID = mxid("archivist-bot") # _PASTE_MIN_CHARS = 100 inside the bot — gates `_looks_like_paste`. diff --git a/tests/integration/test_demo_rig_e2e.py b/tests/e2e/test_demo_rig_e2e.py similarity index 99% rename from tests/integration/test_demo_rig_e2e.py rename to tests/e2e/test_demo_rig_e2e.py index cee04b5e..9ea7dfbc 100644 --- a/tests/integration/test_demo_rig_e2e.py +++ b/tests/e2e/test_demo_rig_e2e.py @@ -4,7 +4,7 @@ this checkout. They do not seed config, start stacklets, or stub the AI endpoint. Use: - tests/integration/stacktests demo-rig -s + tests/e2e/stacktests demo-rig -s Each test uses a unique token and private room/file names so repeated runs can coexist with the demo. Paperless and Forgejo artifacts are @@ -24,9 +24,9 @@ from nio.api import RoomVisibility from nio.responses import JoinedMembersResponse, RoomInviteResponse, RoomSendResponse -from tests.integration.conftest import REPO_ROOT -from tests.integration.forgejo import ForgejoError -from tests.integration.matrix import ( +from tests.e2e.conftest import REPO_ROOT +from tests.e2e.forgejo import ForgejoError +from tests.e2e.matrix import ( SYNAPSE_URL, deactivate_user, ensure_joined, diff --git a/tests/integration/test_email_imap_e2e.py b/tests/e2e/test_email_imap_e2e.py similarity index 100% rename from tests/integration/test_email_imap_e2e.py rename to tests/e2e/test_email_imap_e2e.py diff --git a/tests/integration/test_git_mirror_e2e.py b/tests/e2e/test_git_mirror_e2e.py similarity index 97% rename from tests/integration/test_git_mirror_e2e.py rename to tests/e2e/test_git_mirror_e2e.py index 9bb1a9ea..5ebff933 100644 --- a/tests/integration/test_git_mirror_e2e.py +++ b/tests/e2e/test_git_mirror_e2e.py @@ -12,7 +12,7 @@ Run with `-s` to stream the BDD narration live: - uv run --extra test pytest -s tests/integration/test_git_mirror_e2e.py + uv run --extra test pytest -s tests/e2e/test_git_mirror_e2e.py """ from __future__ import annotations @@ -20,14 +20,14 @@ import asyncio -from tests.integration.forgejo import ForgejoError -from tests.integration.matrix import ( +from tests.e2e.forgejo import ForgejoError +from tests.e2e.matrix import ( room_alias, ensure_joined, upload_and_send_file, wait_for_room, ) -from tests.integration.openai_stub import stub_classify, stub_reformat +from tests.e2e.openai_stub import stub_classify, stub_reformat DOCS_ROOM_ALIAS = room_alias("documents") diff --git a/tests/integration/test_markdown_mirror_e2e.py b/tests/e2e/test_markdown_mirror_e2e.py similarity index 97% rename from tests/integration/test_markdown_mirror_e2e.py rename to tests/e2e/test_markdown_mirror_e2e.py index 48450597..c45e729e 100644 --- a/tests/integration/test_markdown_mirror_e2e.py +++ b/tests/e2e/test_markdown_mirror_e2e.py @@ -15,7 +15,7 @@ Run with `-s` to stream the BDD narration live: - uv run --extra test pytest -s tests/integration/test_markdown_mirror_e2e.py + uv run --extra test pytest -s tests/e2e/test_markdown_mirror_e2e.py """ from __future__ import annotations @@ -23,13 +23,13 @@ import asyncio import pytest -from tests.integration.matrix import ( +from tests.e2e.matrix import ( room_alias, ensure_joined, upload_and_send_file, wait_for_room, ) -from tests.integration.openai_stub import stub_classify +from tests.e2e.openai_stub import stub_classify DOCS_ROOM_ALIAS = room_alias("documents") diff --git a/tests/integration/test_microbot_transport_e2e.py b/tests/e2e/test_microbot_transport_e2e.py similarity index 96% rename from tests/integration/test_microbot_transport_e2e.py rename to tests/e2e/test_microbot_transport_e2e.py index 216b31fe..d38c75ed 100644 --- a/tests/integration/test_microbot_transport_e2e.py +++ b/tests/e2e/test_microbot_transport_e2e.py @@ -5,7 +5,7 @@ replied-to message), `_download_media` (authenticated media fetch) — verified against a live Synapse rather than a stub. -The FakeClient tests in tests/stacklets/test_microbot.py pin the +The FakeClient tests in tests/unit/stacklets/test_microbot.py pin the *content-dict shape* fast and offline; these pin that Synapse actually round-trips it — the kind of assumption a stub can quietly get wrong: @@ -18,8 +18,8 @@ Run via the rig (brings up `messages`/Synapse, Simpsons family logged in): - tests/integration/stacktests pytest \ - tests/integration/test_microbot_transport_e2e.py + tests/e2e/stacktests pytest \ + tests/e2e/test_microbot_transport_e2e.py """ from __future__ import annotations @@ -35,7 +35,7 @@ sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) from microbot import MicroBot # noqa: E402 -from tests.integration.matrix import wait_for_room_event # noqa: E402 +from tests.e2e.matrix import wait_for_room_event # noqa: E402 def _bot_over(client) -> MicroBot: diff --git a/tests/integration/test_rig.py b/tests/e2e/test_rig.py similarity index 94% rename from tests/integration/test_rig.py rename to tests/e2e/test_rig.py index d262ae17..4eb4e3d5 100644 --- a/tests/integration/test_rig.py +++ b/tests/e2e/test_rig.py @@ -45,7 +45,7 @@ async def test_homer_is_logged_in(homer): async def test_openai_mock_serves_canned_response(openai): """pytest-httpserver mock returns the stubbed classification payload.""" import aiohttp - from tests.integration.openai_stub import stub_classify + from tests.e2e.openai_stub import stub_classify stub_classify(openai, {"title": "Test", "topics": ["Demo"]}) # The stub routes by prompt content -- send a classify-shaped one. @@ -65,9 +65,9 @@ async def test_openai_mock_serves_canned_response(openai): def test_two_scopes_dont_collide(paperless, request): """Two consecutive scopes should have different uids and not clean each other's entities.""" - from tests.integration.conftest import Scope + from tests.e2e.conftest import Scope import uuid - from tests.integration.paperless import cleanup_prefix + from tests.e2e.paperless import cleanup_prefix a = Scope(uid=f"t-{uuid.uuid4().hex[:8]}") b = Scope(uid=f"t-{uuid.uuid4().hex[:8]}") diff --git a/tests/integration/test_room_context_e2e.py b/tests/e2e/test_room_context_e2e.py similarity index 98% rename from tests/integration/test_room_context_e2e.py rename to tests/e2e/test_room_context_e2e.py index 1df76e46..af6671c6 100644 --- a/tests/integration/test_room_context_e2e.py +++ b/tests/e2e/test_room_context_e2e.py @@ -17,7 +17,7 @@ The "bot" here is a logged-in family member (Bart); the other members (Homer, Lisa) play the humans. Run on a clean instance: - tests/integration/stacktests pytest tests/integration/test_room_context_e2e.py + tests/e2e/stacktests pytest tests/e2e/test_room_context_e2e.py """ from __future__ import annotations @@ -33,7 +33,7 @@ sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) from microbot import MicroBot # noqa: E402 -from tests.integration.matrix import wait_for_room_event # noqa: E402 +from tests.e2e.matrix import wait_for_room_event # noqa: E402 class _Bot(MicroBot): diff --git a/tests/integration/test_room_modes_e2e.py b/tests/e2e/test_room_modes_e2e.py similarity index 99% rename from tests/integration/test_room_modes_e2e.py rename to tests/e2e/test_room_modes_e2e.py index 1619b1d6..25c29bda 100644 --- a/tests/integration/test_room_modes_e2e.py +++ b/tests/e2e/test_room_modes_e2e.py @@ -44,7 +44,7 @@ from nio import AsyncClient -from tests.integration.matrix import ( +from tests.e2e.matrix import ( mxid, event_type, fetch_room_events, diff --git a/tests/integration/test_source_archive_e2e.py b/tests/e2e/test_source_archive_e2e.py similarity index 97% rename from tests/integration/test_source_archive_e2e.py rename to tests/e2e/test_source_archive_e2e.py index 428c3c12..11b273ec 100644 --- a/tests/integration/test_source_archive_e2e.py +++ b/tests/e2e/test_source_archive_e2e.py @@ -16,12 +16,12 @@ Run with `-s` to stream the BDD narration: - uv run --extra test pytest -s tests/integration/test_source_archive_e2e.py + uv run --extra test pytest -s tests/e2e/test_source_archive_e2e.py """ from __future__ import annotations -from tests.integration.matrix import mxid +from tests.e2e.matrix import mxid import asyncio import io @@ -31,7 +31,7 @@ from PIL import Image from pypdf import PdfReader -from tests.integration.openai_stub import stub_classify, stub_reformat +from tests.e2e.openai_stub import stub_classify, stub_reformat # The framework contract the archivist consumes — deliberately spelled out @@ -115,7 +115,7 @@ async def test_paperclip_react_archives_source_card_to_paperless( # the archivist would also classify the attachment on arrival (as a # capture), making an LLM call the single-shot stub can't serve. This # also mirrors the real UX: mail rooms run in react mode. - from tests.integration.matrix import wait_for_room_event + from tests.e2e.matrix import wait_for_room_event # Wait for the bot's join welcome first. The drain anchors its per-room # cursor at "now" on first sight and skips everything older, so a config diff --git a/tests/integration/test_state_api_e2e.py b/tests/e2e/test_state_api_e2e.py similarity index 97% rename from tests/integration/test_state_api_e2e.py rename to tests/e2e/test_state_api_e2e.py index 623067b1..bcddea43 100644 --- a/tests/integration/test_state_api_e2e.py +++ b/tests/e2e/test_state_api_e2e.py @@ -3,7 +3,7 @@ Exercises each method against the live test stack — real containers, real [health] probes. Complements the unit tests in -tests/framework/test_stack_health.py which mock the HTTP side. +tests/unit/framework/test_stack_health.py which mock the HTTP side. """ from __future__ import annotations diff --git a/tests/stacklets/test_backup_e2e.py b/tests/integration/test_backup_e2e.py similarity index 99% rename from tests/stacklets/test_backup_e2e.py rename to tests/integration/test_backup_e2e.py index 5a0bf53c..cc3c857e 100644 --- a/tests/stacklets/test_backup_e2e.py +++ b/tests/integration/test_backup_e2e.py @@ -38,11 +38,11 @@ reason="backup E2E tests require macOS (hdiutil + chflags + APFS)", ) -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / - "stacklets" / "backup" / "cli")) +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT / "lib")) +sys.path.insert(0, str(REPO_ROOT / "stacklets" / "backup" / "cli")) from migrate import migrate_vault # noqa: E402 -REPO_ROOT = Path(__file__).resolve().parents[2] ENGINE_SCRIPT = REPO_ROOT / "stacklets" / "backup" / "engines" / "external-disk" / "sync.py" STACK_BIN = REPO_ROOT / "stack" diff --git a/tests/framework/test_config_to_container.py b/tests/integration/test_config_to_container.py similarity index 99% rename from tests/framework/test_config_to_container.py rename to tests/integration/test_config_to_container.py index 24d611ca..3f60c314 100644 --- a/tests/framework/test_config_to_container.py +++ b/tests/integration/test_config_to_container.py @@ -18,7 +18,7 @@ import pytest REPO_ROOT = Path(__file__).parent.parent.parent -FIXTURES_DIR = Path(__file__).parent / "fixtures" +FIXTURES_DIR = REPO_ROOT / "tests" / "unit" / "framework" / "fixtures" # Skip entire module if Docker is not available try: diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/framework/__init__.py b/tests/unit/framework/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/framework/conftest.py b/tests/unit/framework/conftest.py similarity index 96% rename from tests/framework/conftest.py rename to tests/unit/framework/conftest.py index ebf59bed..41368bb5 100644 --- a/tests/framework/conftest.py +++ b/tests/unit/framework/conftest.py @@ -11,7 +11,7 @@ import pytest # Ensure lib/ is importable -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) FIXTURES_DIR = Path(__file__).parent / "fixtures" @@ -93,7 +93,7 @@ def _factory(stacklets=None, extensions=None, config_extra="", output=None): # ── Legacy fixtures for tests that still call the CLI as subprocess ──────── # These will be removed as tests migrate to direct Stack usage. -REPO_ROOT = Path(__file__).parent.parent.parent +REPO_ROOT = Path(__file__).parent.parent.parent.parent STACK_DIR = REPO_ROOT / ".stack" diff --git a/tests/framework/fixtures/basic/stacklet.toml b/tests/unit/framework/fixtures/basic/stacklet.toml similarity index 100% rename from tests/framework/fixtures/basic/stacklet.toml rename to tests/unit/framework/fixtures/basic/stacklet.toml diff --git a/tests/framework/fixtures/guest/docker-compose.yml b/tests/unit/framework/fixtures/guest/docker-compose.yml similarity index 100% rename from tests/framework/fixtures/guest/docker-compose.yml rename to tests/unit/framework/fixtures/guest/docker-compose.yml diff --git a/tests/framework/fixtures/guest/hooks/on_install.py b/tests/unit/framework/fixtures/guest/hooks/on_install.py similarity index 100% rename from tests/framework/fixtures/guest/hooks/on_install.py rename to tests/unit/framework/fixtures/guest/hooks/on_install.py diff --git a/tests/framework/fixtures/guest/stacklet.toml b/tests/unit/framework/fixtures/guest/stacklet.toml similarity index 100% rename from tests/framework/fixtures/guest/stacklet.toml rename to tests/unit/framework/fixtures/guest/stacklet.toml diff --git a/tests/framework/fixtures/incubating/stacklet.toml b/tests/unit/framework/fixtures/incubating/stacklet.toml similarity index 100% rename from tests/framework/fixtures/incubating/stacklet.toml rename to tests/unit/framework/fixtures/incubating/stacklet.toml diff --git a/tests/framework/fixtures/test/docker-compose.yml b/tests/unit/framework/fixtures/test/docker-compose.yml similarity index 100% rename from tests/framework/fixtures/test/docker-compose.yml rename to tests/unit/framework/fixtures/test/docker-compose.yml diff --git a/tests/framework/fixtures/test/hooks/on_install.py b/tests/unit/framework/fixtures/test/hooks/on_install.py similarity index 100% rename from tests/framework/fixtures/test/hooks/on_install.py rename to tests/unit/framework/fixtures/test/hooks/on_install.py diff --git a/tests/framework/fixtures/test/hooks/on_install_success.py b/tests/unit/framework/fixtures/test/hooks/on_install_success.py similarity index 100% rename from tests/framework/fixtures/test/hooks/on_install_success.py rename to tests/unit/framework/fixtures/test/hooks/on_install_success.py diff --git a/tests/framework/fixtures/test/stacklet.toml b/tests/unit/framework/fixtures/test/stacklet.toml similarity index 100% rename from tests/framework/fixtures/test/stacklet.toml rename to tests/unit/framework/fixtures/test/stacklet.toml diff --git a/tests/framework/fixtures/with_deps/stacklet.toml b/tests/unit/framework/fixtures/with_deps/stacklet.toml similarity index 100% rename from tests/framework/fixtures/with_deps/stacklet.toml rename to tests/unit/framework/fixtures/with_deps/stacklet.toml diff --git a/tests/framework/fixtures/with_hooks/hooks/on_install.py b/tests/unit/framework/fixtures/with_hooks/hooks/on_install.py similarity index 100% rename from tests/framework/fixtures/with_hooks/hooks/on_install.py rename to tests/unit/framework/fixtures/with_hooks/hooks/on_install.py diff --git a/tests/framework/fixtures/with_hooks/hooks/on_stop.sh b/tests/unit/framework/fixtures/with_hooks/hooks/on_stop.sh similarity index 100% rename from tests/framework/fixtures/with_hooks/hooks/on_stop.sh rename to tests/unit/framework/fixtures/with_hooks/hooks/on_stop.sh diff --git a/tests/framework/fixtures/with_hooks/stacklet.toml b/tests/unit/framework/fixtures/with_hooks/stacklet.toml similarity index 100% rename from tests/framework/fixtures/with_hooks/stacklet.toml rename to tests/unit/framework/fixtures/with_hooks/stacklet.toml diff --git a/tests/framework/fixtures/with_secrets/stacklet.toml b/tests/unit/framework/fixtures/with_secrets/stacklet.toml similarity index 100% rename from tests/framework/fixtures/with_secrets/stacklet.toml rename to tests/unit/framework/fixtures/with_secrets/stacklet.toml diff --git a/tests/framework/test_ai_backend.py b/tests/unit/framework/test_ai_backend.py similarity index 98% rename from tests/framework/test_ai_backend.py rename to tests/unit/framework/test_ai_backend.py index 008728c8..e458d540 100644 --- a/tests/framework/test_ai_backend.py +++ b/tests/unit/framework/test_ai_backend.py @@ -8,7 +8,7 @@ # backend.py lives in stacklets/ai/, not in a package -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "stacklets" / "ai")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "stacklets" / "ai")) # ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/tests/framework/test_ai_client.py b/tests/unit/framework/test_ai_client.py similarity index 99% rename from tests/framework/test_ai_client.py rename to tests/unit/framework/test_ai_client.py index f8660271..546132db 100644 --- a/tests/framework/test_ai_client.py +++ b/tests/unit/framework/test_ai_client.py @@ -22,7 +22,7 @@ from pytest_httpserver import HTTPServer # The framework lives in lib/. -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) import stack.ai.models # noqa: E402 — patched module-side, see fixture from stack.ai.client import ( # noqa: E402 diff --git a/tests/framework/test_ai_mode.py b/tests/unit/framework/test_ai_mode.py similarity index 98% rename from tests/framework/test_ai_mode.py rename to tests/unit/framework/test_ai_mode.py index 3ae66d13..fa984178 100644 --- a/tests/framework/test_ai_mode.py +++ b/tests/unit/framework/test_ai_mode.py @@ -9,7 +9,7 @@ import pytest -from tests.integration._ai_mode import ( +from tests.e2e._ai_mode import ( MOCK_MODEL, MOCK_URL, AIModeError, diff --git a/tests/framework/test_ai_on_start.py b/tests/unit/framework/test_ai_on_start.py similarity index 97% rename from tests/framework/test_ai_on_start.py rename to tests/unit/framework/test_ai_on_start.py index 3bd88df0..6785d815 100644 --- a/tests/framework/test_ai_on_start.py +++ b/tests/unit/framework/test_ai_on_start.py @@ -14,7 +14,7 @@ import pytest -REPO = Path(__file__).parent.parent.parent +REPO = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(REPO / "lib")) diff --git a/tests/framework/test_ai_transcripts.py b/tests/unit/framework/test_ai_transcripts.py similarity index 99% rename from tests/framework/test_ai_transcripts.py rename to tests/unit/framework/test_ai_transcripts.py index 5d770437..fa32a35e 100644 --- a/tests/framework/test_ai_transcripts.py +++ b/tests/unit/framework/test_ai_transcripts.py @@ -11,7 +11,7 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) from stack.ai.client import LLMUnavailableError # noqa: E402 from stack.ai.transcripts import ( # noqa: E402 diff --git a/tests/framework/test_capabilities.py b/tests/unit/framework/test_capabilities.py similarity index 98% rename from tests/framework/test_capabilities.py rename to tests/unit/framework/test_capabilities.py index 1ad56496..95411d11 100644 --- a/tests/framework/test_capabilities.py +++ b/tests/unit/framework/test_capabilities.py @@ -6,7 +6,7 @@ from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(REPO_ROOT / "lib")) from stack.ai.client import ModelCapabilities diff --git a/tests/framework/test_check_versions.py b/tests/unit/framework/test_check_versions.py similarity index 95% rename from tests/framework/test_check_versions.py rename to tests/unit/framework/test_check_versions.py index 31dec6ab..fc44a2ac 100644 --- a/tests/framework/test_check_versions.py +++ b/tests/unit/framework/test_check_versions.py @@ -11,7 +11,7 @@ import pytest -from tests.integration._check_versions import _normalise, _readme_version +from tests.e2e._check_versions import _normalise, _readme_version @pytest.mark.parametrize( @@ -66,7 +66,7 @@ def test_the_current_release_claim_is_found_and_parses(self): assert _readme_version(), "README must state which release is current" def test_it_agrees_with_the_shipped_version(self): - from tests.integration._check_versions import ( + from tests.e2e._check_versions import ( _cli_version, _pyproject_version, ) assert _normalise(_readme_version()) == _normalise(_cli_version()) diff --git a/tests/framework/test_cli_class.py b/tests/unit/framework/test_cli_class.py similarity index 99% rename from tests/framework/test_cli_class.py rename to tests/unit/framework/test_cli_class.py index ebcf14d0..bdebbca2 100644 --- a/tests/framework/test_cli_class.py +++ b/tests/unit/framework/test_cli_class.py @@ -11,7 +11,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) @pytest.fixture(autouse=True) diff --git a/tests/framework/test_commands.py b/tests/unit/framework/test_commands.py similarity index 98% rename from tests/framework/test_commands.py rename to tests/unit/framework/test_commands.py index 7a8d118b..31a5bdb7 100644 --- a/tests/framework/test_commands.py +++ b/tests/unit/framework/test_commands.py @@ -15,7 +15,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) @pytest.fixture(autouse=True) diff --git a/tests/framework/test_commit_lint.py b/tests/unit/framework/test_commit_lint.py similarity index 99% rename from tests/framework/test_commit_lint.py rename to tests/unit/framework/test_commit_lint.py index c9ae905e..ab123cbf 100644 --- a/tests/framework/test_commit_lint.py +++ b/tests/unit/framework/test_commit_lint.py @@ -16,7 +16,7 @@ import pytest -REPO_ROOT = Path(__file__).resolve().parent.parent.parent +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent # The tool has no .py extension, matching tools/branch-status, so it is # loaded by path rather than imported. diff --git a/tests/framework/test_compose_bind_mounts.py b/tests/unit/framework/test_compose_bind_mounts.py similarity index 98% rename from tests/framework/test_compose_bind_mounts.py rename to tests/unit/framework/test_compose_bind_mounts.py index 28b4d6a4..30038736 100644 --- a/tests/framework/test_compose_bind_mounts.py +++ b/tests/unit/framework/test_compose_bind_mounts.py @@ -21,7 +21,7 @@ import re from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent.parent +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent # `- ./source:/container/target[:ro]` in a compose volumes list. Sources # built from ${VARIABLES} are host data directories, not repo files, and diff --git a/tests/framework/test_compose_pins.py b/tests/unit/framework/test_compose_pins.py similarity index 98% rename from tests/framework/test_compose_pins.py rename to tests/unit/framework/test_compose_pins.py index 06704625..4e2c9411 100644 --- a/tests/framework/test_compose_pins.py +++ b/tests/unit/framework/test_compose_pins.py @@ -13,7 +13,7 @@ import re from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent.parent +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent # `image: repo/name:tag` in compose, ignoring commented-out lines. The tag is # optional in the grammar precisely because omitting it means `:latest`, which diff --git a/tests/framework/test_dependencies.py b/tests/unit/framework/test_dependencies.py similarity index 100% rename from tests/framework/test_dependencies.py rename to tests/unit/framework/test_dependencies.py diff --git a/tests/framework/test_discovery.py b/tests/unit/framework/test_discovery.py similarity index 100% rename from tests/framework/test_discovery.py rename to tests/unit/framework/test_discovery.py diff --git a/tests/framework/test_docker_ids.py b/tests/unit/framework/test_docker_ids.py similarity index 100% rename from tests/framework/test_docker_ids.py rename to tests/unit/framework/test_docker_ids.py diff --git a/tests/framework/test_docker_runtime.py b/tests/unit/framework/test_docker_runtime.py similarity index 100% rename from tests/framework/test_docker_runtime.py rename to tests/unit/framework/test_docker_runtime.py diff --git a/tests/framework/test_doctor.py b/tests/unit/framework/test_doctor.py similarity index 100% rename from tests/framework/test_doctor.py rename to tests/unit/framework/test_doctor.py diff --git a/tests/framework/test_env_rendering.py b/tests/unit/framework/test_env_rendering.py similarity index 100% rename from tests/framework/test_env_rendering.py rename to tests/unit/framework/test_env_rendering.py diff --git a/tests/framework/test_extensions.py b/tests/unit/framework/test_extensions.py similarity index 99% rename from tests/framework/test_extensions.py rename to tests/unit/framework/test_extensions.py index 1941748e..982beb19 100644 --- a/tests/framework/test_extensions.py +++ b/tests/unit/framework/test_extensions.py @@ -14,7 +14,7 @@ import sys from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent.parent +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent CORE = REPO_ROOT / "stacklets" / "core" FIXTURES_DIR = Path(__file__).parent / "fixtures" diff --git a/tests/framework/test_family_name.py b/tests/unit/framework/test_family_name.py similarity index 96% rename from tests/framework/test_family_name.py rename to tests/unit/framework/test_family_name.py index a2259e96..b23eba29 100644 --- a/tests/framework/test_family_name.py +++ b/tests/unit/framework/test_family_name.py @@ -14,7 +14,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) from stack import family_display_name, family_plural diff --git a/tests/framework/test_health_check_skip.py b/tests/unit/framework/test_health_check_skip.py similarity index 98% rename from tests/framework/test_health_check_skip.py rename to tests/unit/framework/test_health_check_skip.py index 82388797..b0cd1937 100644 --- a/tests/framework/test_health_check_skip.py +++ b/tests/unit/framework/test_health_check_skip.py @@ -15,7 +15,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) from stack.stack import Stack # noqa: E402 diff --git a/tests/framework/test_hook_ctx_shell.py b/tests/unit/framework/test_hook_ctx_shell.py similarity index 97% rename from tests/framework/test_hook_ctx_shell.py rename to tests/unit/framework/test_hook_ctx_shell.py index c34d5c08..38dabebe 100644 --- a/tests/framework/test_hook_ctx_shell.py +++ b/tests/unit/framework/test_hook_ctx_shell.py @@ -11,7 +11,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) @pytest.fixture(autouse=True) diff --git a/tests/framework/test_hook_resolver.py b/tests/unit/framework/test_hook_resolver.py similarity index 99% rename from tests/framework/test_hook_resolver.py rename to tests/unit/framework/test_hook_resolver.py index 482a8afd..77d2b711 100644 --- a/tests/framework/test_hook_resolver.py +++ b/tests/unit/framework/test_hook_resolver.py @@ -20,7 +20,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) @pytest.fixture(autouse=True) diff --git a/tests/framework/test_hook_secrets.py b/tests/unit/framework/test_hook_secrets.py similarity index 100% rename from tests/framework/test_hook_secrets.py rename to tests/unit/framework/test_hook_secrets.py diff --git a/tests/framework/test_hook_shell_env.py b/tests/unit/framework/test_hook_shell_env.py similarity index 97% rename from tests/framework/test_hook_shell_env.py rename to tests/unit/framework/test_hook_shell_env.py index d252c5a4..b6bc354a 100644 --- a/tests/framework/test_hook_shell_env.py +++ b/tests/unit/framework/test_hook_shell_env.py @@ -12,7 +12,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) @pytest.fixture(autouse=True) diff --git a/tests/framework/test_hooks.py b/tests/unit/framework/test_hooks.py similarity index 100% rename from tests/framework/test_hooks.py rename to tests/unit/framework/test_hooks.py diff --git a/tests/framework/test_links.py b/tests/unit/framework/test_links.py similarity index 98% rename from tests/framework/test_links.py rename to tests/unit/framework/test_links.py index cc054699..08ffd25c 100644 --- a/tests/framework/test_links.py +++ b/tests/unit/framework/test_links.py @@ -17,7 +17,7 @@ from stack.links import go_docs, go_person, go_topic, public -REPO_ROOT = Path(__file__).resolve().parent.parent.parent +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent # ── What the builder promises ──────────────────────────────────────────── diff --git a/tests/framework/test_list_doc.py b/tests/unit/framework/test_list_doc.py similarity index 99% rename from tests/framework/test_list_doc.py rename to tests/unit/framework/test_list_doc.py index c25bbfdf..3da3ea95 100644 --- a/tests/framework/test_list_doc.py +++ b/tests/unit/framework/test_list_doc.py @@ -22,7 +22,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) from stack.list_doc import diff, parse # noqa: E402 diff --git a/tests/framework/test_mail_env.py b/tests/unit/framework/test_mail_env.py similarity index 99% rename from tests/framework/test_mail_env.py rename to tests/unit/framework/test_mail_env.py index f0a2d6c8..1087b812 100644 --- a/tests/framework/test_mail_env.py +++ b/tests/unit/framework/test_mail_env.py @@ -11,7 +11,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "lib")) +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "lib")) from stack.mail_fetcher import ( # noqa: E402 _imap_since, diff --git a/tests/framework/test_media_archive.py b/tests/unit/framework/test_media_archive.py similarity index 100% rename from tests/framework/test_media_archive.py rename to tests/unit/framework/test_media_archive.py diff --git a/tests/framework/test_microbot_cursor.py b/tests/unit/framework/test_microbot_cursor.py similarity index 98% rename from tests/framework/test_microbot_cursor.py rename to tests/unit/framework/test_microbot_cursor.py index 1234bc88..9af043ab 100644 --- a/tests/framework/test_microbot_cursor.py +++ b/tests/unit/framework/test_microbot_cursor.py @@ -23,7 +23,7 @@ Requires matrix-nio (real import, not mocked). Only loguru is stubbed since it's just logging. Run with: - uvx --with loguru --with matrix-nio pytest tests/framework/test_microbot_cursor.py + uvx --with loguru --with matrix-nio pytest tests/unit/framework/test_microbot_cursor.py """ import asyncio @@ -42,7 +42,7 @@ sys.modules["loguru"] = _loguru try: - sys.path.insert(0, str(Path(__file__).parent.parent.parent / "stacklets" / "core" / "bot-runner")) + sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "stacklets" / "core" / "bot-runner")) from microbot import MicroBot from nio import RoomMessagesResponse, SyncResponse except ImportError: diff --git a/tests/framework/test_models.py b/tests/unit/framework/test_models.py similarity index 98% rename from tests/framework/test_models.py rename to tests/unit/framework/test_models.py index 958ac31e..c2f9c713 100644 --- a/tests/framework/test_models.py +++ b/tests/unit/framework/test_models.py @@ -19,7 +19,7 @@ import pytest # The shared library lives in lib/ at the repo root -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) import stack import stack.ai.models # noqa: F401 (test patches `_DEFAULT_MODEL`/`_MODELS` on this module) diff --git a/tests/framework/test_ontology.py b/tests/unit/framework/test_ontology.py similarity index 100% rename from tests/framework/test_ontology.py rename to tests/unit/framework/test_ontology.py diff --git a/tests/framework/test_page_patch.py b/tests/unit/framework/test_page_patch.py similarity index 98% rename from tests/framework/test_page_patch.py rename to tests/unit/framework/test_page_patch.py index 5fb9beee..2ad46715 100644 --- a/tests/framework/test_page_patch.py +++ b/tests/unit/framework/test_page_patch.py @@ -26,7 +26,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) from stack.page_patch import PatchError, apply_edits, edits_from # noqa: E402 diff --git a/tests/framework/test_postgres_helper.py b/tests/unit/framework/test_postgres_helper.py similarity index 99% rename from tests/framework/test_postgres_helper.py rename to tests/unit/framework/test_postgres_helper.py index f561692e..4f9152b0 100644 --- a/tests/framework/test_postgres_helper.py +++ b/tests/unit/framework/test_postgres_helper.py @@ -15,7 +15,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) from stack.postgres import dump_command, restore_command, version_command # noqa: E402 diff --git a/tests/framework/test_prompt.py b/tests/unit/framework/test_prompt.py similarity index 100% rename from tests/framework/test_prompt.py rename to tests/unit/framework/test_prompt.py diff --git a/tests/framework/test_secret_store.py b/tests/unit/framework/test_secret_store.py similarity index 98% rename from tests/framework/test_secret_store.py rename to tests/unit/framework/test_secret_store.py index cdf4e42f..e1a8c0b1 100644 --- a/tests/framework/test_secret_store.py +++ b/tests/unit/framework/test_secret_store.py @@ -16,7 +16,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) @pytest.fixture(autouse=True) diff --git a/tests/framework/test_secrets.py b/tests/unit/framework/test_secrets.py similarity index 100% rename from tests/framework/test_secrets.py rename to tests/unit/framework/test_secrets.py diff --git a/tests/framework/test_stack_class.py b/tests/unit/framework/test_stack_class.py similarity index 99% rename from tests/framework/test_stack_class.py rename to tests/unit/framework/test_stack_class.py index 904b7980..b237adb7 100644 --- a/tests/framework/test_stack_class.py +++ b/tests/unit/framework/test_stack_class.py @@ -12,7 +12,7 @@ import pytest -REPO_ROOT = Path(__file__).parent.parent.parent +REPO_ROOT = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(REPO_ROOT / "lib")) diff --git a/tests/framework/test_stack_health.py b/tests/unit/framework/test_stack_health.py similarity index 98% rename from tests/framework/test_stack_health.py rename to tests/unit/framework/test_stack_health.py index 055e4ed6..dee256a5 100644 --- a/tests/framework/test_stack_health.py +++ b/tests/unit/framework/test_stack_health.py @@ -11,7 +11,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) @pytest.fixture(autouse=True) diff --git a/tests/framework/test_stack_lifecycle.py b/tests/unit/framework/test_stack_lifecycle.py similarity index 99% rename from tests/framework/test_stack_lifecycle.py rename to tests/unit/framework/test_stack_lifecycle.py index bd59dc99..4ca0d9e4 100644 --- a/tests/framework/test_stack_lifecycle.py +++ b/tests/unit/framework/test_stack_lifecycle.py @@ -21,7 +21,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) @pytest.fixture(autouse=True) diff --git a/tests/framework/test_stack_output.py b/tests/unit/framework/test_stack_output.py similarity index 98% rename from tests/framework/test_stack_output.py rename to tests/unit/framework/test_stack_output.py index d04ba08f..fdd87150 100644 --- a/tests/framework/test_stack_output.py +++ b/tests/unit/framework/test_stack_output.py @@ -18,7 +18,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) @pytest.fixture(autouse=True) diff --git a/tests/framework/test_stack_secrets.py b/tests/unit/framework/test_stack_secrets.py similarity index 98% rename from tests/framework/test_stack_secrets.py rename to tests/unit/framework/test_stack_secrets.py index b9982051..36cdb758 100644 --- a/tests/framework/test_stack_secrets.py +++ b/tests/unit/framework/test_stack_secrets.py @@ -11,7 +11,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) @pytest.fixture(autouse=True) diff --git a/tests/framework/test_strip_mention.py b/tests/unit/framework/test_strip_mention.py similarity index 98% rename from tests/framework/test_strip_mention.py rename to tests/unit/framework/test_strip_mention.py index 673d6a5c..8d642db0 100644 --- a/tests/framework/test_strip_mention.py +++ b/tests/unit/framework/test_strip_mention.py @@ -14,7 +14,7 @@ sys.path.insert( - 0, str(Path(__file__).resolve().parent.parent.parent + 0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "core" / "bot-runner"), ) diff --git a/tests/framework/test_uninstall.py b/tests/unit/framework/test_uninstall.py similarity index 99% rename from tests/framework/test_uninstall.py rename to tests/unit/framework/test_uninstall.py index b109ba81..73a3784b 100644 --- a/tests/framework/test_uninstall.py +++ b/tests/unit/framework/test_uninstall.py @@ -8,7 +8,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) @pytest.fixture(autouse=True) diff --git a/tests/framework/test_updater.py b/tests/unit/framework/test_updater.py similarity index 100% rename from tests/framework/test_updater.py rename to tests/unit/framework/test_updater.py diff --git a/tests/framework/test_user_id.py b/tests/unit/framework/test_user_id.py similarity index 95% rename from tests/framework/test_user_id.py rename to tests/unit/framework/test_user_id.py index c1546a36..d832cd9f 100644 --- a/tests/framework/test_user_id.py +++ b/tests/unit/framework/test_user_id.py @@ -15,7 +15,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "lib")) from stack import user_id diff --git a/tests/framework/test_web_fetch.py b/tests/unit/framework/test_web_fetch.py similarity index 100% rename from tests/framework/test_web_fetch.py rename to tests/unit/framework/test_web_fetch.py diff --git a/tests/framework/test_web_profiles.py b/tests/unit/framework/test_web_profiles.py similarity index 100% rename from tests/framework/test_web_profiles.py rename to tests/unit/framework/test_web_profiles.py diff --git a/tests/framework/test_web_quality.py b/tests/unit/framework/test_web_quality.py similarity index 100% rename from tests/framework/test_web_quality.py rename to tests/unit/framework/test_web_quality.py diff --git a/tests/framework/test_web_structured.py b/tests/unit/framework/test_web_structured.py similarity index 100% rename from tests/framework/test_web_structured.py rename to tests/unit/framework/test_web_structured.py diff --git a/tests/stacklets/conftest.py b/tests/unit/stacklets/conftest.py similarity index 99% rename from tests/stacklets/conftest.py rename to tests/unit/stacklets/conftest.py index 311d6707..da4df961 100644 --- a/tests/stacklets/conftest.py +++ b/tests/unit/stacklets/conftest.py @@ -16,7 +16,7 @@ import pytest -REPO_ROOT = Path(__file__).resolve().parent.parent.parent +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent # Make the framework importable for in-process tests (CLI subprocess # calls already set PYTHONPATH; this is for tests that import lib.py diff --git a/tests/stacklets/test_agent_config.py b/tests/unit/stacklets/test_agent_config.py similarity index 93% rename from tests/stacklets/test_agent_config.py rename to tests/unit/stacklets/test_agent_config.py index c42c667d..5d93e419 100644 --- a/tests/stacklets/test_agent_config.py +++ b/tests/unit/stacklets/test_agent_config.py @@ -12,7 +12,7 @@ import json from pathlib import Path -CONFIG = Path(__file__).resolve().parents[2] / "stacklets" / "agent" / "config.json" +CONFIG = Path(__file__).resolve().parents[3] / "stacklets" / "agent" / "config.json" def _config() -> dict: diff --git a/tests/stacklets/test_agent_join_greeting.py b/tests/unit/stacklets/test_agent_join_greeting.py similarity index 98% rename from tests/stacklets/test_agent_join_greeting.py rename to tests/unit/stacklets/test_agent_join_greeting.py index a72efa02..f74eb64d 100644 --- a/tests/stacklets/test_agent_join_greeting.py +++ b/tests/unit/stacklets/test_agent_join_greeting.py @@ -13,7 +13,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "agent" / "runtime")) from brief import topic_for_room_label # noqa: E402 diff --git a/tests/stacklets/test_agent_name_trigger.py b/tests/unit/stacklets/test_agent_name_trigger.py similarity index 98% rename from tests/stacklets/test_agent_name_trigger.py rename to tests/unit/stacklets/test_agent_name_trigger.py index 2908ea00..6e73349d 100644 --- a/tests/stacklets/test_agent_name_trigger.py +++ b/tests/unit/stacklets/test_agent_name_trigger.py @@ -17,7 +17,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "agent" / "runtime")) from name_trigger import addressed_by_name # noqa: E402 diff --git a/tests/stacklets/test_agent_runtime_shims.py b/tests/unit/stacklets/test_agent_runtime_shims.py similarity index 100% rename from tests/stacklets/test_agent_runtime_shims.py rename to tests/unit/stacklets/test_agent_runtime_shims.py diff --git a/tests/stacklets/test_agent_thread_trigger.py b/tests/unit/stacklets/test_agent_thread_trigger.py similarity index 99% rename from tests/stacklets/test_agent_thread_trigger.py rename to tests/unit/stacklets/test_agent_thread_trigger.py index b68116c0..7c76116a 100644 --- a/tests/stacklets/test_agent_thread_trigger.py +++ b/tests/unit/stacklets/test_agent_thread_trigger.py @@ -28,7 +28,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "agent" / "runtime")) from thread_trigger import AgentThreads, thread_root # noqa: E402 diff --git a/tests/stacklets/test_agent_vault_tools.py b/tests/unit/stacklets/test_agent_vault_tools.py similarity index 99% rename from tests/stacklets/test_agent_vault_tools.py rename to tests/unit/stacklets/test_agent_vault_tools.py index e7761e67..c4cd430a 100644 --- a/tests/stacklets/test_agent_vault_tools.py +++ b/tests/unit/stacklets/test_agent_vault_tools.py @@ -33,7 +33,7 @@ import pytest -REPO_ROOT = Path(__file__).resolve().parent.parent.parent +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent MEMORY_DIR = REPO_ROOT / "stacklets" / "memory" TOOL_MODULES = ("memory_tool", "person_tool", "history_tool", "sitecustomize") diff --git a/tests/stacklets/test_ai_omlx_install.py b/tests/unit/stacklets/test_ai_omlx_install.py similarity index 98% rename from tests/stacklets/test_ai_omlx_install.py rename to tests/unit/stacklets/test_ai_omlx_install.py index 9b8e29a0..0442bf53 100644 --- a/tests/stacklets/test_ai_omlx_install.py +++ b/tests/unit/stacklets/test_ai_omlx_install.py @@ -16,7 +16,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) # Every stacklet has a hook module named `on_install`, so a plain diff --git a/tests/stacklets/test_ai_start_guard.py b/tests/unit/stacklets/test_ai_start_guard.py similarity index 97% rename from tests/stacklets/test_ai_start_guard.py rename to tests/unit/stacklets/test_ai_start_guard.py index 0a9d5bd8..db74db5d 100644 --- a/tests/stacklets/test_ai_start_guard.py +++ b/tests/unit/stacklets/test_ai_start_guard.py @@ -18,7 +18,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) # Every stacklet has hooks with the same module names, so load this one diff --git a/tests/stacklets/test_archivist_corrections.py b/tests/unit/stacklets/test_archivist_corrections.py similarity index 99% rename from tests/stacklets/test_archivist_corrections.py rename to tests/unit/stacklets/test_archivist_corrections.py index 03344a53..c19abe2e 100644 --- a/tests/stacklets/test_archivist_corrections.py +++ b/tests/unit/stacklets/test_archivist_corrections.py @@ -38,7 +38,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) diff --git a/tests/stacklets/test_archivist_duplicate.py b/tests/unit/stacklets/test_archivist_duplicate.py similarity index 97% rename from tests/stacklets/test_archivist_duplicate.py rename to tests/unit/stacklets/test_archivist_duplicate.py index 59af572b..f3279a05 100644 --- a/tests/stacklets/test_archivist_duplicate.py +++ b/tests/unit/stacklets/test_archivist_duplicate.py @@ -11,7 +11,7 @@ from pathlib import Path -_REPO_ROOT = Path(__file__).parent.parent.parent +_REPO_ROOT = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) diff --git a/tests/stacklets/test_archivist_matching.py b/tests/unit/stacklets/test_archivist_matching.py similarity index 99% rename from tests/stacklets/test_archivist_matching.py rename to tests/unit/stacklets/test_archivist_matching.py index 534d5f27..d82f1018 100644 --- a/tests/stacklets/test_archivist_matching.py +++ b/tests/unit/stacklets/test_archivist_matching.py @@ -8,7 +8,7 @@ from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "stacklets" / "docs" / "bot")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "stacklets" / "docs" / "bot")) from matching import ( _is_empty, diff --git a/tests/stacklets/test_archivist_routing.py b/tests/unit/stacklets/test_archivist_routing.py similarity index 99% rename from tests/stacklets/test_archivist_routing.py rename to tests/unit/stacklets/test_archivist_routing.py index cdf666f6..b7ac8ae5 100644 --- a/tests/stacklets/test_archivist_routing.py +++ b/tests/unit/stacklets/test_archivist_routing.py @@ -24,7 +24,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) @@ -771,7 +771,7 @@ async def _record_send(*a, **kw): # `_on_text` / `_on_file`. These tests focus on the routing # dispatch, not the welcome -- stub it out so the recorded # calls list stays clean. The welcome itself has its own test - # file: tests/stacklets/test_archivist_welcome.py. + # file: tests/unit/stacklets/test_archivist_welcome.py. bot._send_room_welcome_if_needed = lambda *_a, **_kw: _none_coro() return bot, calls diff --git a/tests/stacklets/test_archivist_source.py b/tests/unit/stacklets/test_archivist_source.py similarity index 99% rename from tests/stacklets/test_archivist_source.py rename to tests/unit/stacklets/test_archivist_source.py index e83887f8..614d0081 100644 --- a/tests/stacklets/test_archivist_source.py +++ b/tests/unit/stacklets/test_archivist_source.py @@ -14,7 +14,7 @@ import pytest -_REPO = Path(__file__).resolve().parent.parent.parent +_REPO = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO / "lib")) sys.path.insert(0, str(_REPO / "stacklets" / "core" / "bot-runner")) sys.path.insert(0, str(_REPO / "stacklets" / "docs" / "bot")) diff --git a/tests/stacklets/test_archivist_topic_bootstrap.py b/tests/unit/stacklets/test_archivist_topic_bootstrap.py similarity index 99% rename from tests/stacklets/test_archivist_topic_bootstrap.py rename to tests/unit/stacklets/test_archivist_topic_bootstrap.py index 94fe1ce0..bfd9cf2d 100644 --- a/tests/stacklets/test_archivist_topic_bootstrap.py +++ b/tests/unit/stacklets/test_archivist_topic_bootstrap.py @@ -18,7 +18,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) diff --git a/tests/stacklets/test_archivist_welcome.py b/tests/unit/stacklets/test_archivist_welcome.py similarity index 99% rename from tests/stacklets/test_archivist_welcome.py rename to tests/unit/stacklets/test_archivist_welcome.py index 27688bcb..d3ef16bf 100644 --- a/tests/stacklets/test_archivist_welcome.py +++ b/tests/unit/stacklets/test_archivist_welcome.py @@ -25,7 +25,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) diff --git a/tests/stacklets/test_backup_config.py b/tests/unit/stacklets/test_backup_config.py similarity index 99% rename from tests/stacklets/test_backup_config.py rename to tests/unit/stacklets/test_backup_config.py index 1071c607..78a2d64d 100644 --- a/tests/stacklets/test_backup_config.py +++ b/tests/unit/stacklets/test_backup_config.py @@ -11,7 +11,7 @@ from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(REPO_ROOT / "stacklets" / "backup")) from _config import read_target, write_target diff --git a/tests/stacklets/test_backup_configure.py b/tests/unit/stacklets/test_backup_configure.py similarity index 99% rename from tests/stacklets/test_backup_configure.py rename to tests/unit/stacklets/test_backup_configure.py index 7f117925..bfbe035d 100644 --- a/tests/stacklets/test_backup_configure.py +++ b/tests/unit/stacklets/test_backup_configure.py @@ -13,7 +13,7 @@ from unittest.mock import patch -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(REPO_ROOT / "stacklets" / "backup" / "hooks")) # Need stack.prompt importable for the module to load — add lib/ to path. diff --git a/tests/stacklets/test_backup_cron.py b/tests/unit/stacklets/test_backup_cron.py similarity index 99% rename from tests/stacklets/test_backup_cron.py rename to tests/unit/stacklets/test_backup_cron.py index e5a3d598..67a83450 100644 --- a/tests/stacklets/test_backup_cron.py +++ b/tests/unit/stacklets/test_backup_cron.py @@ -14,7 +14,7 @@ import pytest -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(REPO_ROOT / "stacklets" / "backup")) import _cron as cron # noqa: E402 diff --git a/tests/stacklets/test_backup_engine.py b/tests/unit/stacklets/test_backup_engine.py similarity index 99% rename from tests/stacklets/test_backup_engine.py rename to tests/unit/stacklets/test_backup_engine.py index 26e5bde7..afcb9032 100644 --- a/tests/stacklets/test_backup_engine.py +++ b/tests/unit/stacklets/test_backup_engine.py @@ -14,7 +14,7 @@ import pytest -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str( REPO_ROOT / "stacklets" / "backup" / "engines" / "external-disk" )) diff --git a/tests/stacklets/test_backup_install.py b/tests/unit/stacklets/test_backup_install.py similarity index 98% rename from tests/stacklets/test_backup_install.py rename to tests/unit/stacklets/test_backup_install.py index 86e22f84..b3df308a 100644 --- a/tests/stacklets/test_backup_install.py +++ b/tests/unit/stacklets/test_backup_install.py @@ -12,7 +12,7 @@ from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(REPO_ROOT / "stacklets" / "backup" / "hooks")) sys.path.insert(0, str(REPO_ROOT / "stacklets" / "backup")) sys.path.insert(0, str(REPO_ROOT / "lib")) diff --git a/tests/stacklets/test_backup_migrate.py b/tests/unit/stacklets/test_backup_migrate.py similarity index 99% rename from tests/stacklets/test_backup_migrate.py rename to tests/unit/stacklets/test_backup_migrate.py index 0e3ae449..7c6b381d 100644 --- a/tests/stacklets/test_backup_migrate.py +++ b/tests/unit/stacklets/test_backup_migrate.py @@ -19,7 +19,7 @@ import pytest -_ROOT = Path(__file__).resolve().parent.parent.parent +_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_ROOT / "stacklets" / "backup" / "cli")) from migrate import declared_sources, migrate_vault # noqa: E402 diff --git a/tests/stacklets/test_backup_orchestrator.py b/tests/unit/stacklets/test_backup_orchestrator.py similarity index 99% rename from tests/stacklets/test_backup_orchestrator.py rename to tests/unit/stacklets/test_backup_orchestrator.py index d5c428f8..57c406ab 100644 --- a/tests/stacklets/test_backup_orchestrator.py +++ b/tests/unit/stacklets/test_backup_orchestrator.py @@ -14,7 +14,7 @@ from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(REPO_ROOT / "stacklets" / "backup" / "cli")) from _orchestrator import ( diff --git a/tests/stacklets/test_backup_snapshot.py b/tests/unit/stacklets/test_backup_snapshot.py similarity index 99% rename from tests/stacklets/test_backup_snapshot.py rename to tests/unit/stacklets/test_backup_snapshot.py index b68fb405..84dffa60 100644 --- a/tests/stacklets/test_backup_snapshot.py +++ b/tests/unit/stacklets/test_backup_snapshot.py @@ -19,7 +19,7 @@ import pytest -_ROOT = Path(__file__).resolve().parent.parent.parent +_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_ROOT / "stacklets" / "backup" / "cli")) from _snapshot import ( # noqa: E402 diff --git a/tests/stacklets/test_backup_status.py b/tests/unit/stacklets/test_backup_status.py similarity index 98% rename from tests/stacklets/test_backup_status.py rename to tests/unit/stacklets/test_backup_status.py index a3a50289..ce3f69fa 100644 --- a/tests/stacklets/test_backup_status.py +++ b/tests/unit/stacklets/test_backup_status.py @@ -13,7 +13,7 @@ import pytest -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(REPO_ROOT / "stacklets" / "backup" / "cli")) sys.path.insert(0, str(REPO_ROOT / "stacklets" / "backup" / "engines" / "external-disk")) sys.path.insert(0, str(REPO_ROOT / "stacklets" / "backup")) diff --git a/tests/stacklets/test_bot_discovery.py b/tests/unit/stacklets/test_bot_discovery.py similarity index 92% rename from tests/stacklets/test_bot_discovery.py rename to tests/unit/stacklets/test_bot_discovery.py index d620f4cc..f6c5660c 100644 --- a/tests/stacklets/test_bot_discovery.py +++ b/tests/unit/stacklets/test_bot_discovery.py @@ -9,7 +9,7 @@ import sys from pathlib import Path -_RUNNER = Path(__file__).resolve().parent.parent.parent / "stacklets" / "core" / "bot-runner" +_RUNNER = Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "core" / "bot-runner" sys.path.insert(0, str(_RUNNER)) from main import _bot_toml_files # noqa: E402 diff --git a/tests/stacklets/test_capture_links.py b/tests/unit/stacklets/test_capture_links.py similarity index 99% rename from tests/stacklets/test_capture_links.py rename to tests/unit/stacklets/test_capture_links.py index 809d4264..0d499f31 100644 --- a/tests/stacklets/test_capture_links.py +++ b/tests/unit/stacklets/test_capture_links.py @@ -18,7 +18,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "tools-server")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) diff --git a/tests/stacklets/test_capture_pipeline.py b/tests/unit/stacklets/test_capture_pipeline.py similarity index 99% rename from tests/stacklets/test_capture_pipeline.py rename to tests/unit/stacklets/test_capture_pipeline.py index 7b2a1b2b..e20d8535 100644 --- a/tests/stacklets/test_capture_pipeline.py +++ b/tests/unit/stacklets/test_capture_pipeline.py @@ -14,7 +14,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) diff --git a/tests/stacklets/test_capture_prompt.py b/tests/unit/stacklets/test_capture_prompt.py similarity index 99% rename from tests/stacklets/test_capture_prompt.py rename to tests/unit/stacklets/test_capture_prompt.py index 421284a5..50013e47 100644 --- a/tests/stacklets/test_capture_prompt.py +++ b/tests/unit/stacklets/test_capture_prompt.py @@ -25,7 +25,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "docs" / "bot")) from pipeline import Classifier, _build_capture_prompt # noqa: E402 diff --git a/tests/stacklets/test_capture_tag_cache.py b/tests/unit/stacklets/test_capture_tag_cache.py similarity index 97% rename from tests/stacklets/test_capture_tag_cache.py rename to tests/unit/stacklets/test_capture_tag_cache.py index 0ffbdd52..d7ec360d 100644 --- a/tests/stacklets/test_capture_tag_cache.py +++ b/tests/unit/stacklets/test_capture_tag_cache.py @@ -18,7 +18,7 @@ import pytest -_BOT_DIR = Path(__file__).resolve().parent.parent.parent / "stacklets" / "docs" / "bot" +_BOT_DIR = Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "docs" / "bot" sys.path.insert(0, str(_BOT_DIR)) from capture_tags import CaptureTagCache # noqa: E402 diff --git a/tests/stacklets/test_classify_prompt_ontology.py b/tests/unit/stacklets/test_classify_prompt_ontology.py similarity index 99% rename from tests/stacklets/test_classify_prompt_ontology.py rename to tests/unit/stacklets/test_classify_prompt_ontology.py index b4cbcecf..47ac06f7 100644 --- a/tests/stacklets/test_classify_prompt_ontology.py +++ b/tests/unit/stacklets/test_classify_prompt_ontology.py @@ -21,7 +21,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "docs" / "bot")) from pipeline import _build_classify_prompt # noqa: E402 @@ -140,7 +140,7 @@ class TestRealOntologyShape: def test_uses_synonyms_inline(self): # Import lazily — keeps the other tests independent of the lib path. - sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent + sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) from stack.ontology import Ontology diff --git a/tests/stacklets/test_cli_id_specs.py b/tests/unit/stacklets/test_cli_id_specs.py similarity index 98% rename from tests/stacklets/test_cli_id_specs.py rename to tests/unit/stacklets/test_cli_id_specs.py index daa08809..a0bcaaf0 100644 --- a/tests/stacklets/test_cli_id_specs.py +++ b/tests/unit/stacklets/test_cli_id_specs.py @@ -14,7 +14,7 @@ import pytest _BOT_DIR = ( - Path(__file__).resolve().parent.parent.parent + Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "docs" / "bot" ) sys.path.insert(0, str(_BOT_DIR)) diff --git a/tests/stacklets/test_core_link_resolver.py b/tests/unit/stacklets/test_core_link_resolver.py similarity index 99% rename from tests/stacklets/test_core_link_resolver.py rename to tests/unit/stacklets/test_core_link_resolver.py index 366ba2aa..a70c8406 100644 --- a/tests/stacklets/test_core_link_resolver.py +++ b/tests/unit/stacklets/test_core_link_resolver.py @@ -13,7 +13,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "tools-server")) from resolver import resolve_person_target, resolve_topic_target # noqa: E402 diff --git a/tests/stacklets/test_diary_store.py b/tests/unit/stacklets/test_diary_store.py similarity index 95% rename from tests/stacklets/test_diary_store.py rename to tests/unit/stacklets/test_diary_store.py index 42a22b53..dd4506a4 100644 --- a/tests/stacklets/test_diary_store.py +++ b/tests/unit/stacklets/test_diary_store.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "stacklets" / "memory" / "bot")) import diary_store # noqa: E402 diff --git a/tests/stacklets/test_docs_seed.py b/tests/unit/stacklets/test_docs_seed.py similarity index 96% rename from tests/stacklets/test_docs_seed.py rename to tests/unit/stacklets/test_docs_seed.py index d65ffda9..15cb7f08 100644 --- a/tests/stacklets/test_docs_seed.py +++ b/tests/unit/stacklets/test_docs_seed.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "stacklets" / "docs")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "stacklets" / "docs")) from seed import _load_taxonomy, TAXONOMY_PATH diff --git a/tests/stacklets/test_docs_token.py b/tests/unit/stacklets/test_docs_token.py similarity index 98% rename from tests/stacklets/test_docs_token.py rename to tests/unit/stacklets/test_docs_token.py index 22ef8e52..68fd802d 100644 --- a/tests/stacklets/test_docs_token.py +++ b/tests/unit/stacklets/test_docs_token.py @@ -24,7 +24,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs")) sys.path.insert(0, str(_REPO_ROOT / "lib")) diff --git a/tests/stacklets/test_document_pipeline.py b/tests/unit/stacklets/test_document_pipeline.py similarity index 99% rename from tests/stacklets/test_document_pipeline.py rename to tests/unit/stacklets/test_document_pipeline.py index a7f043c4..f6bc4e27 100644 --- a/tests/stacklets/test_document_pipeline.py +++ b/tests/unit/stacklets/test_document_pipeline.py @@ -15,7 +15,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) from document_pipeline import DocumentPipeline # noqa: E402 diff --git a/tests/stacklets/test_extractors.py b/tests/unit/stacklets/test_extractors.py similarity index 99% rename from tests/stacklets/test_extractors.py rename to tests/unit/stacklets/test_extractors.py index 1a34761a..71cc34c2 100644 --- a/tests/stacklets/test_extractors.py +++ b/tests/unit/stacklets/test_extractors.py @@ -18,7 +18,7 @@ import aiohttp import pytest -_BOT_DIR = Path(__file__).resolve().parent.parent.parent / "stacklets" / "docs" / "bot" +_BOT_DIR = Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "docs" / "bot" sys.path.insert(0, str(_BOT_DIR)) from extractors import ( # noqa: E402 diff --git a/tests/stacklets/test_frontmatter.py b/tests/unit/stacklets/test_frontmatter.py similarity index 100% rename from tests/stacklets/test_frontmatter.py rename to tests/unit/stacklets/test_frontmatter.py diff --git a/tests/stacklets/test_git_mirror.py b/tests/unit/stacklets/test_git_mirror.py similarity index 99% rename from tests/stacklets/test_git_mirror.py rename to tests/unit/stacklets/test_git_mirror.py index 4de5bc81..c85603e5 100644 --- a/tests/stacklets/test_git_mirror.py +++ b/tests/unit/stacklets/test_git_mirror.py @@ -14,7 +14,7 @@ import pytest -_REPO_ROOT = Path(__file__).parent.parent.parent +_REPO_ROOT = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) diff --git a/tests/stacklets/test_mailbot.py b/tests/unit/stacklets/test_mailbot.py similarity index 99% rename from tests/stacklets/test_mailbot.py rename to tests/unit/stacklets/test_mailbot.py index 9ff1addc..c8637cb8 100644 --- a/tests/stacklets/test_mailbot.py +++ b/tests/unit/stacklets/test_mailbot.py @@ -13,7 +13,7 @@ import pytest -_ROOT = Path(__file__).resolve().parent.parent.parent +_ROOT = Path(__file__).resolve().parent.parent.parent.parent for _p in (_ROOT / "stacklets" / "core" / "bot-runner", _ROOT / "stacklets" / "core" / "bot"): sys.path.insert(0, str(_p)) diff --git a/tests/stacklets/test_matrix_client.py b/tests/unit/stacklets/test_matrix_client.py similarity index 98% rename from tests/stacklets/test_matrix_client.py rename to tests/unit/stacklets/test_matrix_client.py index be5ac5ad..7b79fdc0 100644 --- a/tests/stacklets/test_matrix_client.py +++ b/tests/unit/stacklets/test_matrix_client.py @@ -23,7 +23,7 @@ import pytest from pytest_httpserver import HTTPServer -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "messages" / "cli")) from _matrix import MatrixClient # noqa: E402 diff --git a/tests/stacklets/test_memory_ask.py b/tests/unit/stacklets/test_memory_ask.py similarity index 99% rename from tests/stacklets/test_memory_ask.py rename to tests/unit/stacklets/test_memory_ask.py index 52071034..0775248f 100644 --- a/tests/stacklets/test_memory_ask.py +++ b/tests/unit/stacklets/test_memory_ask.py @@ -26,7 +26,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "cli")) diff --git a/tests/stacklets/test_memory_capture_cli.py b/tests/unit/stacklets/test_memory_capture_cli.py similarity index 99% rename from tests/stacklets/test_memory_capture_cli.py rename to tests/unit/stacklets/test_memory_capture_cli.py index 80e7b978..9c7c5415 100644 --- a/tests/stacklets/test_memory_capture_cli.py +++ b/tests/unit/stacklets/test_memory_capture_cli.py @@ -24,7 +24,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) from capture_pipeline import CaptureOutcome # noqa: E402 diff --git a/tests/stacklets/test_memory_cli.py b/tests/unit/stacklets/test_memory_cli.py similarity index 99% rename from tests/stacklets/test_memory_cli.py rename to tests/unit/stacklets/test_memory_cli.py index ff435333..3953a16b 100644 --- a/tests/stacklets/test_memory_cli.py +++ b/tests/unit/stacklets/test_memory_cli.py @@ -19,7 +19,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "memory" / "cli")) import _todos # noqa: E402 diff --git a/tests/stacklets/test_memory_correspondents.py b/tests/unit/stacklets/test_memory_correspondents.py similarity index 99% rename from tests/stacklets/test_memory_correspondents.py rename to tests/unit/stacklets/test_memory_correspondents.py index b8b1839f..c35f49f7 100644 --- a/tests/stacklets/test_memory_correspondents.py +++ b/tests/unit/stacklets/test_memory_correspondents.py @@ -15,7 +15,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "memory")) from lib import ( # noqa: E402 diff --git a/tests/stacklets/test_memory_curator.py b/tests/unit/stacklets/test_memory_curator.py similarity index 99% rename from tests/stacklets/test_memory_curator.py rename to tests/unit/stacklets/test_memory_curator.py index 70f00335..34b110d8 100644 --- a/tests/stacklets/test_memory_curator.py +++ b/tests/unit/stacklets/test_memory_curator.py @@ -22,7 +22,7 @@ import time from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot" / "cli")) diff --git a/tests/stacklets/test_memory_curator_sync.py b/tests/unit/stacklets/test_memory_curator_sync.py similarity index 99% rename from tests/stacklets/test_memory_curator_sync.py rename to tests/unit/stacklets/test_memory_curator_sync.py index 96d33f65..b4f9d809 100644 --- a/tests/stacklets/test_memory_curator_sync.py +++ b/tests/unit/stacklets/test_memory_curator_sync.py @@ -27,7 +27,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot" / "cli")) diff --git a/tests/stacklets/test_memory_diary.py b/tests/unit/stacklets/test_memory_diary.py similarity index 99% rename from tests/stacklets/test_memory_diary.py rename to tests/unit/stacklets/test_memory_diary.py index f6c85e62..75417f66 100644 --- a/tests/stacklets/test_memory_diary.py +++ b/tests/unit/stacklets/test_memory_diary.py @@ -29,7 +29,7 @@ import pytest import yaml -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot")) sys.path.insert(0, str(_REPO_ROOT / "tools" / "family-memories")) diff --git a/tests/stacklets/test_memory_history_cli.py b/tests/unit/stacklets/test_memory_history_cli.py similarity index 99% rename from tests/stacklets/test_memory_history_cli.py rename to tests/unit/stacklets/test_memory_history_cli.py index efc02296..929c2dad 100644 --- a/tests/stacklets/test_memory_history_cli.py +++ b/tests/unit/stacklets/test_memory_history_cli.py @@ -26,7 +26,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory")) diff --git a/tests/stacklets/test_memory_host_stdlib.py b/tests/unit/stacklets/test_memory_host_stdlib.py similarity index 98% rename from tests/stacklets/test_memory_host_stdlib.py rename to tests/unit/stacklets/test_memory_host_stdlib.py index 864db349..d02fbf90 100644 --- a/tests/stacklets/test_memory_host_stdlib.py +++ b/tests/unit/stacklets/test_memory_host_stdlib.py @@ -29,7 +29,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory")) from lib import ( # noqa: E402 diff --git a/tests/stacklets/test_memory_install_pipeline.py b/tests/unit/stacklets/test_memory_install_pipeline.py similarity index 99% rename from tests/stacklets/test_memory_install_pipeline.py rename to tests/unit/stacklets/test_memory_install_pipeline.py index 8c27f5e7..61e44021 100644 --- a/tests/stacklets/test_memory_install_pipeline.py +++ b/tests/unit/stacklets/test_memory_install_pipeline.py @@ -22,7 +22,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "memory")) from lib import ( # noqa: E402 diff --git a/tests/stacklets/test_memory_install_seeds.py b/tests/unit/stacklets/test_memory_install_seeds.py similarity index 99% rename from tests/stacklets/test_memory_install_seeds.py rename to tests/unit/stacklets/test_memory_install_seeds.py index 3841531e..2d0fa550 100644 --- a/tests/stacklets/test_memory_install_seeds.py +++ b/tests/unit/stacklets/test_memory_install_seeds.py @@ -21,7 +21,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "memory")) from lib import ( # noqa: E402 diff --git a/tests/stacklets/test_memory_list_edit.py b/tests/unit/stacklets/test_memory_list_edit.py similarity index 98% rename from tests/stacklets/test_memory_list_edit.py rename to tests/unit/stacklets/test_memory_list_edit.py index eea9cf67..9fa4507a 100644 --- a/tests/stacklets/test_memory_list_edit.py +++ b/tests/unit/stacklets/test_memory_list_edit.py @@ -18,7 +18,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "cli")) diff --git a/tests/stacklets/test_memory_persons.py b/tests/unit/stacklets/test_memory_persons.py similarity index 99% rename from tests/stacklets/test_memory_persons.py rename to tests/unit/stacklets/test_memory_persons.py index b4415ad6..4553fb81 100644 --- a/tests/stacklets/test_memory_persons.py +++ b/tests/unit/stacklets/test_memory_persons.py @@ -14,7 +14,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "memory")) from lib import ( # noqa: E402 diff --git a/tests/stacklets/test_memory_query_rewrite.py b/tests/unit/stacklets/test_memory_query_rewrite.py similarity index 99% rename from tests/stacklets/test_memory_query_rewrite.py rename to tests/unit/stacklets/test_memory_query_rewrite.py index 56ecb237..b4d40363 100644 --- a/tests/stacklets/test_memory_query_rewrite.py +++ b/tests/unit/stacklets/test_memory_query_rewrite.py @@ -28,7 +28,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) diff --git a/tests/stacklets/test_memory_search.py b/tests/unit/stacklets/test_memory_search.py similarity index 99% rename from tests/stacklets/test_memory_search.py rename to tests/unit/stacklets/test_memory_search.py index d4148c2a..73b011c5 100644 --- a/tests/stacklets/test_memory_search.py +++ b/tests/unit/stacklets/test_memory_search.py @@ -29,7 +29,7 @@ # In-process import for the lib-level test class. The CLI tests below # exercise the same engine via subprocess; these pin the API shape the # archivist bot consumes directly. -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "memory")) from lib import search_memory # noqa: E402 @@ -896,7 +896,7 @@ def _search_cli(): loader does, so `run()` can be driven with a configured data dir.""" import importlib.machinery import importlib.util - path = Path(__file__).resolve().parents[2] / "stacklets/memory/cli/search.py" + path = Path(__file__).resolve().parents[3] / "stacklets/memory/cli/search.py" loader = importlib.machinery.SourceFileLoader("memory_cli_search_tree", str(path)) spec = importlib.util.spec_from_file_location(loader.name, path, loader=loader) module = importlib.util.module_from_spec(spec) diff --git a/tests/stacklets/test_memory_search_nl.py b/tests/unit/stacklets/test_memory_search_nl.py similarity index 99% rename from tests/stacklets/test_memory_search_nl.py rename to tests/unit/stacklets/test_memory_search_nl.py index b72ecb2d..098bb481 100644 --- a/tests/stacklets/test_memory_search_nl.py +++ b/tests/unit/stacklets/test_memory_search_nl.py @@ -28,7 +28,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "cli")) diff --git a/tests/stacklets/test_memory_sync.py b/tests/unit/stacklets/test_memory_sync.py similarity index 99% rename from tests/stacklets/test_memory_sync.py rename to tests/unit/stacklets/test_memory_sync.py index 27b04703..b021632d 100644 --- a/tests/stacklets/test_memory_sync.py +++ b/tests/unit/stacklets/test_memory_sync.py @@ -26,7 +26,7 @@ import threading from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent _MEMORY_DIR = _REPO_ROOT / "stacklets" / "memory" sys.path.insert(0, str(_MEMORY_DIR)) diff --git a/tests/stacklets/test_memory_token_repair.py b/tests/unit/stacklets/test_memory_token_repair.py similarity index 99% rename from tests/stacklets/test_memory_token_repair.py rename to tests/unit/stacklets/test_memory_token_repair.py index e245ae1d..428ee00a 100644 --- a/tests/stacklets/test_memory_token_repair.py +++ b/tests/unit/stacklets/test_memory_token_repair.py @@ -37,7 +37,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "hooks")) diff --git a/tests/stacklets/test_memory_vault.py b/tests/unit/stacklets/test_memory_vault.py similarity index 99% rename from tests/stacklets/test_memory_vault.py rename to tests/unit/stacklets/test_memory_vault.py index 48445a7f..2d3887b2 100644 --- a/tests/stacklets/test_memory_vault.py +++ b/tests/unit/stacklets/test_memory_vault.py @@ -14,7 +14,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "memory")) from lib import ( # noqa: E402 diff --git a/tests/stacklets/test_memory_wiki.py b/tests/unit/stacklets/test_memory_wiki.py similarity index 99% rename from tests/stacklets/test_memory_wiki.py rename to tests/unit/stacklets/test_memory_wiki.py index e3c6b841..4039d96c 100644 --- a/tests/stacklets/test_memory_wiki.py +++ b/tests/unit/stacklets/test_memory_wiki.py @@ -18,7 +18,7 @@ import yaml -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot" / "cli")) diff --git a/tests/stacklets/test_memory_write_cli.py b/tests/unit/stacklets/test_memory_write_cli.py similarity index 99% rename from tests/stacklets/test_memory_write_cli.py rename to tests/unit/stacklets/test_memory_write_cli.py index f808c257..4959b816 100644 --- a/tests/stacklets/test_memory_write_cli.py +++ b/tests/unit/stacklets/test_memory_write_cli.py @@ -29,7 +29,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory")) diff --git a/tests/stacklets/test_messages_matrix_power_levels.py b/tests/unit/stacklets/test_messages_matrix_power_levels.py similarity index 99% rename from tests/stacklets/test_messages_matrix_power_levels.py rename to tests/unit/stacklets/test_messages_matrix_power_levels.py index cb06904a..01f541e0 100644 --- a/tests/stacklets/test_messages_matrix_power_levels.py +++ b/tests/unit/stacklets/test_messages_matrix_power_levels.py @@ -22,7 +22,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "messages" / "cli")) import _matrix as _matrix_mod # noqa: E402 diff --git a/tests/stacklets/test_messages_rate_limits.py b/tests/unit/stacklets/test_messages_rate_limits.py similarity index 97% rename from tests/stacklets/test_messages_rate_limits.py rename to tests/unit/stacklets/test_messages_rate_limits.py index d9164bdf..02d2e8cf 100644 --- a/tests/stacklets/test_messages_rate_limits.py +++ b/tests/unit/stacklets/test_messages_rate_limits.py @@ -19,7 +19,7 @@ from pathlib import Path from types import SimpleNamespace -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) _spec = importlib.util.spec_from_file_location( diff --git a/tests/stacklets/test_messages_send_as.py b/tests/unit/stacklets/test_messages_send_as.py similarity index 94% rename from tests/stacklets/test_messages_send_as.py rename to tests/unit/stacklets/test_messages_send_as.py index 711c5f48..af7935c6 100644 --- a/tests/stacklets/test_messages_send_as.py +++ b/tests/unit/stacklets/test_messages_send_as.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "messages" / "cli")) from _matrix import resolve_login # noqa: E402 diff --git a/tests/stacklets/test_messages_setup.py b/tests/unit/stacklets/test_messages_setup.py similarity index 98% rename from tests/stacklets/test_messages_setup.py rename to tests/unit/stacklets/test_messages_setup.py index 511f0912..b251109e 100644 --- a/tests/stacklets/test_messages_setup.py +++ b/tests/unit/stacklets/test_messages_setup.py @@ -8,7 +8,7 @@ from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "messages" / "cli")) from setup import _setup # type: ignore diff --git a/tests/stacklets/test_messages_threading.py b/tests/unit/stacklets/test_messages_threading.py similarity index 98% rename from tests/stacklets/test_messages_threading.py rename to tests/unit/stacklets/test_messages_threading.py index e1daab44..e667a4cf 100644 --- a/tests/stacklets/test_messages_threading.py +++ b/tests/unit/stacklets/test_messages_threading.py @@ -19,7 +19,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "messages" / "cli")) import _matrix # noqa: E402 diff --git a/tests/stacklets/test_microbot.py b/tests/unit/stacklets/test_microbot.py similarity index 99% rename from tests/stacklets/test_microbot.py rename to tests/unit/stacklets/test_microbot.py index ee2c29e5..93ac94eb 100644 --- a/tests/stacklets/test_microbot.py +++ b/tests/unit/stacklets/test_microbot.py @@ -20,7 +20,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) from microbot import MicroBot # noqa: E402 @@ -750,7 +750,7 @@ class TestReplyParentEnvelope: reclassify, which id) is the caller's job — this just retrieves it. Real-Synapse round-trip is covered by - tests/integration/test_microbot_transport_e2e.py; these pin the + tests/e2e/test_microbot_transport_e2e.py; these pin the branch logic offline.""" @staticmethod diff --git a/tests/stacklets/test_microbot_source.py b/tests/unit/stacklets/test_microbot_source.py similarity index 98% rename from tests/stacklets/test_microbot_source.py rename to tests/unit/stacklets/test_microbot_source.py index 9475dfcf..ffddafd0 100644 --- a/tests/stacklets/test_microbot_source.py +++ b/tests/unit/stacklets/test_microbot_source.py @@ -13,7 +13,7 @@ import pytest -_RUNNER = Path(__file__).resolve().parent.parent.parent / "stacklets" / "core" / "bot-runner" +_RUNNER = Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "core" / "bot-runner" sys.path.insert(0, str(_RUNNER)) from microbot import MicroBot # noqa: E402 diff --git a/tests/stacklets/test_microbot_voice.py b/tests/unit/stacklets/test_microbot_voice.py similarity index 99% rename from tests/stacklets/test_microbot_voice.py rename to tests/unit/stacklets/test_microbot_voice.py index 3fdea56f..b10f8160 100644 --- a/tests/stacklets/test_microbot_voice.py +++ b/tests/unit/stacklets/test_microbot_voice.py @@ -16,11 +16,11 @@ import pytest from nio.events.room_events import RoomMessage, RoomMessageAudio, RoomMessageText -_RUNNER = Path(__file__).resolve().parent.parent.parent / "stacklets" / "core" / "bot-runner" +_RUNNER = Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "core" / "bot-runner" sys.path.insert(0, str(_RUNNER)) # `lib/` hosts `stack.ai.client`, which the framework imports for the # Transcriber type. -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "lib")) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) import voice # noqa: E402 from microbot import MicroBot # noqa: E402 diff --git a/tests/stacklets/test_nl_query.py b/tests/unit/stacklets/test_nl_query.py similarity index 99% rename from tests/stacklets/test_nl_query.py rename to tests/unit/stacklets/test_nl_query.py index ccdd3ac3..3e240ff3 100644 --- a/tests/stacklets/test_nl_query.py +++ b/tests/unit/stacklets/test_nl_query.py @@ -13,7 +13,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) from nl_query import ( # noqa: E402 diff --git a/tests/stacklets/test_notifier.py b/tests/unit/stacklets/test_notifier.py similarity index 97% rename from tests/stacklets/test_notifier.py rename to tests/unit/stacklets/test_notifier.py index 4a0c7a93..e18e2fb3 100644 --- a/tests/stacklets/test_notifier.py +++ b/tests/unit/stacklets/test_notifier.py @@ -13,7 +13,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) from notifier import MatrixNotifier # noqa: E402 diff --git a/tests/stacklets/test_omlx_client.py b/tests/unit/stacklets/test_omlx_client.py similarity index 98% rename from tests/stacklets/test_omlx_client.py rename to tests/unit/stacklets/test_omlx_client.py index 67ef37e5..e4c9a245 100644 --- a/tests/stacklets/test_omlx_client.py +++ b/tests/unit/stacklets/test_omlx_client.py @@ -6,7 +6,7 @@ # Add stacklets/ai to path -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "stacklets" / "ai")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "stacklets" / "ai")) from omlx import OMLXClient, DownloadTask, repo_id_to_model_id diff --git a/tests/stacklets/test_pdf_analysis.py b/tests/unit/stacklets/test_pdf_analysis.py similarity index 99% rename from tests/stacklets/test_pdf_analysis.py rename to tests/unit/stacklets/test_pdf_analysis.py index 1adf54ab..18692aad 100644 --- a/tests/stacklets/test_pdf_analysis.py +++ b/tests/unit/stacklets/test_pdf_analysis.py @@ -11,7 +11,7 @@ import pytest -REPO_ROOT = Path(__file__).resolve().parent.parent.parent +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(REPO_ROOT / "stacklets" / "docs" / "bot")) from pdf_analysis import ( diff --git a/tests/stacklets/test_pdf_render.py b/tests/unit/stacklets/test_pdf_render.py similarity index 98% rename from tests/stacklets/test_pdf_render.py rename to tests/unit/stacklets/test_pdf_render.py index 2574a799..bd7fb46c 100644 --- a/tests/stacklets/test_pdf_render.py +++ b/tests/unit/stacklets/test_pdf_render.py @@ -7,7 +7,7 @@ from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(REPO_ROOT / "stacklets" / "docs" / "bot")) from pdf_render import render_pages diff --git a/tests/stacklets/test_photos_import.py b/tests/unit/stacklets/test_photos_import.py similarity index 98% rename from tests/stacklets/test_photos_import.py rename to tests/unit/stacklets/test_photos_import.py index 34387907..249feb16 100644 --- a/tests/stacklets/test_photos_import.py +++ b/tests/unit/stacklets/test_photos_import.py @@ -12,14 +12,14 @@ import pytest sys.path.insert( - 0, str(Path(__file__).resolve().parent.parent.parent + 0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "photos" / "cli"), ) import importlib.util _spec = importlib.util.spec_from_file_location( "album_import", - Path(__file__).resolve().parent.parent.parent + Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "photos" / "cli" / "import.py", ) _mod = importlib.util.module_from_spec(_spec) diff --git a/tests/stacklets/test_pipeline.py b/tests/unit/stacklets/test_pipeline.py similarity index 99% rename from tests/stacklets/test_pipeline.py rename to tests/unit/stacklets/test_pipeline.py index c25c7b3e..e73f2501 100644 --- a/tests/stacklets/test_pipeline.py +++ b/tests/unit/stacklets/test_pipeline.py @@ -14,7 +14,7 @@ import pytest -_REPO_ROOT = Path(__file__).parent.parent.parent +_REPO_ROOT = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) @@ -1452,7 +1452,7 @@ async def test_non_image_mime_filtered(self): # The recall-mode query rewrite used to be tested here. It now lives in # memory, which owns the vault it searches, and so do its tests: -# tests/stacklets/test_memory_query_rewrite.py. +# tests/unit/stacklets/test_memory_query_rewrite.py. # ── PaperlessAPI.wait_task — task-status polling ────────────────────── @@ -1561,7 +1561,7 @@ async def test_a_2x_duplicate_is_a_failed_task_naming_the_twin(self): # is not academic: reading the source suggested a duplicate task ends as # `status: "success"` with no related documents, and the live server # reports `failure` with the twin's id present. The e2e in -# tests/integration/test_archivist_paperless_api_e2e.py::TestTaskApiShape +# tests/e2e/test_archivist_paperless_api_e2e.py::TestTaskApiShape # is what keeps this honest when upstream moves again. _V3_DUPLICATE_TASK = { "count": 1, diff --git a/tests/stacklets/test_recall.py b/tests/unit/stacklets/test_recall.py similarity index 99% rename from tests/stacklets/test_recall.py rename to tests/unit/stacklets/test_recall.py index 35d6c11e..37f084b1 100644 --- a/tests/stacklets/test_recall.py +++ b/tests/unit/stacklets/test_recall.py @@ -18,7 +18,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) diff --git a/tests/stacklets/test_reply_presenter.py b/tests/unit/stacklets/test_reply_presenter.py similarity index 99% rename from tests/stacklets/test_reply_presenter.py rename to tests/unit/stacklets/test_reply_presenter.py index 0d615fe6..efce335e 100644 --- a/tests/stacklets/test_reply_presenter.py +++ b/tests/unit/stacklets/test_reply_presenter.py @@ -13,7 +13,7 @@ from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) from reply_presenter import ( # noqa: E402 diff --git a/tests/stacklets/test_room_context.py b/tests/unit/stacklets/test_room_context.py similarity index 99% rename from tests/stacklets/test_room_context.py rename to tests/unit/stacklets/test_room_context.py index 466cdf52..c7c64cde 100644 --- a/tests/stacklets/test_room_context.py +++ b/tests/unit/stacklets/test_room_context.py @@ -18,7 +18,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) from room_context import RoomContext, context_for, normalize_alias # noqa: E402 diff --git a/tests/stacklets/test_scribe.py b/tests/unit/stacklets/test_scribe.py similarity index 98% rename from tests/stacklets/test_scribe.py rename to tests/unit/stacklets/test_scribe.py index edc9b7b7..10acc2aa 100644 --- a/tests/stacklets/test_scribe.py +++ b/tests/unit/stacklets/test_scribe.py @@ -16,7 +16,7 @@ import pytest -_ROOT = Path(__file__).resolve().parent.parent.parent +_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_ROOT / "stacklets" / "core" / "bot-runner")) sys.path.insert(0, str(_ROOT / "stacklets" / "messages" / "bot")) sys.path.insert(0, str(_ROOT / "lib")) diff --git a/tests/stacklets/test_search_format.py b/tests/unit/stacklets/test_search_format.py similarity index 99% rename from tests/stacklets/test_search_format.py rename to tests/unit/stacklets/test_search_format.py index 992155c5..881f90f0 100644 --- a/tests/stacklets/test_search_format.py +++ b/tests/unit/stacklets/test_search_format.py @@ -11,7 +11,7 @@ import sys from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) from search_format import ( # noqa: E402 diff --git a/tests/stacklets/test_search_service.py b/tests/unit/stacklets/test_search_service.py similarity index 98% rename from tests/stacklets/test_search_service.py rename to tests/unit/stacklets/test_search_service.py index 7ae113d1..34c236ba 100644 --- a/tests/stacklets/test_search_service.py +++ b/tests/unit/stacklets/test_search_service.py @@ -14,7 +14,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent # `stacklets/` for the sibling `memory.lib`; the bot dir for search_service # and its peers (nl_query, recall, search_format) — mirrors archivist's own # sys.path setup so the module imports standalone in tests. diff --git a/tests/stacklets/test_source_archive.py b/tests/unit/stacklets/test_source_archive.py similarity index 98% rename from tests/stacklets/test_source_archive.py rename to tests/unit/stacklets/test_source_archive.py index e358bae8..fcd3d260 100644 --- a/tests/stacklets/test_source_archive.py +++ b/tests/unit/stacklets/test_source_archive.py @@ -17,7 +17,7 @@ # The bot modules live outside the package tree; add the archivist bot dir # to the path exactly as the container does. sys.path.insert( - 0, str(Path(__file__).resolve().parents[2] / "stacklets" / "docs" / "bot") + 0, str(Path(__file__).resolve().parents[3] / "stacklets" / "docs" / "bot") ) from source_archive import ( # noqa: E402 diff --git a/tests/stacklets/test_stacker.py b/tests/unit/stacklets/test_stacker.py similarity index 97% rename from tests/stacklets/test_stacker.py rename to tests/unit/stacklets/test_stacker.py index 53aba536..0500c1ed 100644 --- a/tests/stacklets/test_stacker.py +++ b/tests/unit/stacklets/test_stacker.py @@ -16,7 +16,7 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot-runner")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "bot")) diff --git a/tests/stacklets/test_state_log.py b/tests/unit/stacklets/test_state_log.py similarity index 99% rename from tests/stacklets/test_state_log.py rename to tests/unit/stacklets/test_state_log.py index 80584410..d37e56cf 100644 --- a/tests/stacklets/test_state_log.py +++ b/tests/unit/stacklets/test_state_log.py @@ -8,7 +8,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "agent" / "runtime")) from state_log import format_state_for_log # noqa: E402 diff --git a/tests/stacklets/test_text_utils.py b/tests/unit/stacklets/test_text_utils.py similarity index 99% rename from tests/stacklets/test_text_utils.py rename to tests/unit/stacklets/test_text_utils.py index 84e6c6c6..a99de302 100644 --- a/tests/stacklets/test_text_utils.py +++ b/tests/unit/stacklets/test_text_utils.py @@ -9,7 +9,7 @@ from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent.parent +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(REPO_ROOT / "stacklets" / "docs" / "bot")) from text_utils import ( diff --git a/tests/stacklets/test_todo_list.py b/tests/unit/stacklets/test_todo_list.py similarity index 99% rename from tests/stacklets/test_todo_list.py rename to tests/unit/stacklets/test_todo_list.py index df2324cc..d348a5b3 100644 --- a/tests/stacklets/test_todo_list.py +++ b/tests/unit/stacklets/test_todo_list.py @@ -12,7 +12,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "memory" / "bot" / "cli")) import pytest # noqa: E402 diff --git a/tests/stacklets/test_topic_rooms.py b/tests/unit/stacklets/test_topic_rooms.py similarity index 99% rename from tests/stacklets/test_topic_rooms.py rename to tests/unit/stacklets/test_topic_rooms.py index 9c36b2ae..a95d56d9 100644 --- a/tests/stacklets/test_topic_rooms.py +++ b/tests/unit/stacklets/test_topic_rooms.py @@ -25,7 +25,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "stacklets" / "docs" / "bot")) from topic_rooms import ( # noqa: E402 diff --git a/tests/stacklets/test_vault_context.py b/tests/unit/stacklets/test_vault_context.py similarity index 96% rename from tests/stacklets/test_vault_context.py rename to tests/unit/stacklets/test_vault_context.py index 6a99de10..ce8d2447 100644 --- a/tests/stacklets/test_vault_context.py +++ b/tests/unit/stacklets/test_vault_context.py @@ -12,7 +12,7 @@ from pathlib import Path -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets")) # memory.lib sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) diff --git a/tests/stacklets/test_vault_entry.py b/tests/unit/stacklets/test_vault_entry.py similarity index 99% rename from tests/stacklets/test_vault_entry.py rename to tests/unit/stacklets/test_vault_entry.py index 6ddca06c..8d8fc4c0 100644 --- a/tests/stacklets/test_vault_entry.py +++ b/tests/unit/stacklets/test_vault_entry.py @@ -8,7 +8,7 @@ from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent.parent +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(REPO_ROOT / "stacklets" / "docs" / "bot")) from vault_entry import ( From 87d64555c766b4b104996a2c089175df6a266021 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 09:54:51 +0200 Subject: [PATCH 8/8] ci: reject tool credit lines in commits and pull requests Commits and PR descriptions here carry no credit for the tool that helped write them. `tools/commit-lint` now refuses a Co-Authored-By trailer, the tool's generated-with footer, its product link and an Anthropic noreply address. It reads whole messages rather than only subjects, so the `commit-msg` hook stops the commit, CI stops the PR, and a new `--body` input checks the PR description. CI also runs again when a description is edited. The product name on its own is not a pattern: commits that describe the tool, as two on main do, still pass. --- .github/workflows/ci.yml | 3 + docs/agent/dev.md | 2 +- hooks/commit-msg | 5 +- script/cibuild | 3 + tests/unit/framework/test_commit_lint.py | 81 ++++++++++++++++++++++++ tools/commit-lint | 75 ++++++++++++++++++---- 6 files changed, 152 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eccd728b..23bec08d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ on: branches: [main] tags: ["v*"] pull_request: + # `edited` so a description changed after opening is checked again. + types: [opened, synchronize, reopened, edited] jobs: checks: @@ -26,6 +28,7 @@ jobs: env: GITHUB_REF: ${{ github.ref }} PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} BASE: ${{ github.event.pull_request.base.sha }} HEAD: ${{ github.event.pull_request.head.sha }} run: script/cibuild diff --git a/docs/agent/dev.md b/docs/agent/dev.md index 03cff776..8517b257 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -386,7 +386,7 @@ subject is not: it appears in plain-text contexts too, including | `Upgrade: ` | Renders as **Action required** at the top of the release. Any update needing a human step: a backup, a config edit, a one-way migration. | | `BREAKING CHANGE: ` | Same section, for something that breaks an existing setup rather than asking for a step. | | `Refs: FAM-12` | Links the tracker card. | -| `Co-Authored-By:` | Never. Project rule. | +| `Co-Authored-By:` | Never. Project rule, enforced: `tools/commit-lint` rejects the trailer, a "Generated with Claude Code" line, its link and an Anthropic noreply address, in commit messages and in PR descriptions. | `tools/commit-lint` enforces the header, and is the same check in every place that matters: the `commit-msg` hook rejects it before the commit diff --git a/hooks/commit-msg b/hooks/commit-msg index 6871a8db..ade11279 100755 --- a/hooks/commit-msg +++ b/hooks/commit-msg @@ -1,5 +1,6 @@ #!/bin/sh -# Block commit subjects that would not parse as a changelog entry. +# Block commit subjects that would not parse as a changelog entry, and +# messages that carry a credit line for the tool that wrote them. # # Enable once per clone (same switch as the pre-commit hook): # git config core.hooksPath hooks @@ -9,6 +10,6 @@ # commit that passes here passes there. if ! python3 tools/commit-lint --message "$1"; then - echo " Fix the subject, or commit with --no-verify to bypass." >&2 + echo " Fix the message, or commit with --no-verify to bypass." >&2 exit 1 fi diff --git a/script/cibuild b/script/cibuild index 419668bf..814566d6 100755 --- a/script/cibuild +++ b/script/cibuild @@ -9,6 +9,8 @@ # GITHUB_REF on a version tag, the tag must match the versions # PR_TITLE BASE HEAD on a pull request, the title and every commit # subject in BASE..HEAD must parse as a changelog entry +# PR_BODY and neither the commits nor the description may +# carry a credit line for the tool that wrote them set -euo pipefail @@ -60,6 +62,7 @@ if [[ -n "${PR_TITLE:-}" ]]; then git fetch --no-tags --depth=50 origin "$BASE" "$HEAD" 2>/dev/null || true python3 tools/commit-lint --title "$PR_TITLE" python3 tools/commit-lint --range "$BASE..$HEAD" + python3 tools/commit-lint --body "${PR_BODY:-}" fi echo "==> unit tests" diff --git a/tests/unit/framework/test_commit_lint.py b/tests/unit/framework/test_commit_lint.py index ab123cbf..adaef6b4 100644 --- a/tests/unit/framework/test_commit_lint.py +++ b/tests/unit/framework/test_commit_lint.py @@ -196,3 +196,84 @@ def test_only_the_first_line_of_a_message_is_the_subject(self, tmp_path): "A body line that would never pass as a subject.\n" ) assert self._run("--message", str(message)).returncode == 0 + + +# ── No tool attribution ────────────────────────────────────────────────── + +class TestNoToolAttribution: + """Commits and pull requests carry no credit line for the tool that + helped write them: no Co-Authored-By trailer, no "Generated with" + footer. A description of the tool is fine; an advertisement is not.""" + + @pytest.mark.parametrize("text", [ + "Co-Authored-By: Claude Opus 5 (1M context) ", + "co-authored-by: Someone ", + "🤖 Generated with [Claude Code](https://claude.com/claude-code)", + "Generated with Claude Code", + "See https://claude.com/claude-code", + "Signed off by noreply@anthropic.com", + ]) + def test_credit_lines_are_found(self, text): + assert commit_lint.attribution(f"Summary line.\n\n{text}\n") != [] + + @pytest.mark.parametrize("text", [ + "a language server was wired up. In Claude Code the `LSP` tool is", + "Claude Code reads CLAUDE.md, not AGENTS.md, so the repo rules were never", + "The co-author of the ADR is named in the header.", + ]) + def test_describing_the_tool_is_not_attribution(self, text): + """Both sentences are from commits already on main.""" + assert commit_lint.attribution(text) == [] + + +class TestAttributionOnTheCommandLine: + + def _run(self, *args, cwd=None): + return subprocess.run( + [sys.executable, str(REPO_ROOT / "tools" / "commit-lint"), *args], + capture_output=True, text=True, cwd=cwd or REPO_ROOT, + ) + + def test_a_trailer_in_the_message_body_fails_the_commit(self, tmp_path): + message = tmp_path / "COMMIT_EDITMSG" + message.write_text( + "fix(core): answer the first message\n\nBody.\n\n" + "Co-Authored-By: Claude \n") + result = self._run("--message", str(message)) + assert result.returncode == 1 + assert "Co-Authored-By" in result.stderr + + def test_gits_comment_lines_are_not_the_message(self, tmp_path): + """The editor template is stripped by git before the commit + exists, so whatever it says is not part of what lands.""" + message = tmp_path / "COMMIT_EDITMSG" + message.write_text( + "fix(core): answer the first message\n\n" + "# Generated with Claude Code, in a comment git will strip\n") + assert self._run("--message", str(message)).returncode == 0 + + def test_a_pull_request_body_with_a_footer_fails(self): + result = self._run("--body", "## Summary\n- a change\n\n" + "🤖 Generated with [Claude Code](https://claude.com/claude-code)\n") + assert result.returncode == 1 + assert "Generated with" in result.stderr + + def test_a_clean_pull_request_body_passes(self): + assert self._run("--body", "## Summary\n- a change\n").returncode == 0 + + def test_a_range_fails_on_a_trailer_in_any_body(self, tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + for name, message in [ + ("a", "feat(photos): back up over Wi-Fi"), + ("b", "fix(core): answer the first message\n\n" + "Co-Authored-By: Claude "), + ]: + (repo / name).write_text(name) + subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(repo), "-c", "user.email=t@example.com", + "-c", "user.name=T", "commit", "-qm", message], check=True) + result = self._run("--range", "HEAD~1..HEAD", cwd=repo) + assert result.returncode == 1 + assert "Co-Authored-By" in result.stderr diff --git a/tools/commit-lint b/tools/commit-lint index ac29df5e..9440c758 100755 --- a/tools/commit-lint +++ b/tools/commit-lint @@ -6,12 +6,18 @@ subject is the changelog"), which only works if every subject parses. This is the deterministic half of that: the same rules, applied the same way, by a hook before the commit exists and by CI before it merges. -Three ways in, one rule set: +It also refuses credit lines for the tool that helped write a change: +a Co-Authored-By trailer, a "Generated with Claude Code" footer, its +link, or an Anthropic noreply address. That is checked in whole +messages and in pull request descriptions, not just in subjects. + +Four ways in, one rule set: tools/commit-lint --message .git/COMMIT_EDITMSG one message, for the hook tools/commit-lint --range origin/main..HEAD a range, for CI and releases tools/commit-lint --title "feat(photos): ..." a PR title, which squash merging turns into a subject + tools/commit-lint --body "## Summary ..." a PR description """ from __future__ import annotations @@ -134,6 +140,38 @@ def check(header: str) -> list[str]: return problems +# Credit lines for the tool, never a description of it: "In Claude Code +# the LSP tool is deferred" is documentation and must pass, which is why +# the product name alone is not a pattern. +ATTRIBUTION = [ + (re.compile(r"^\s*co-authored-by:", re.I | re.M), + "a Co-Authored-By trailer; commits here carry none"), + (re.compile(r"generated\s+(with|by)\s+\[?claude", re.I), + "a 'Generated with Claude Code' line"), + (re.compile(r"claude\.com/claude-code", re.I), + "a link to claude.com/claude-code"), + (re.compile(r"noreply@anthropic\.com", re.I), + "an anthropic.com noreply address"), +] + + +def attribution(text: str) -> list[str]: + """Every credit line for the tool found in a message or description.""" + return [why for pattern, why in ATTRIBUTION if pattern.search(text)] + + +def without_comments(message: str) -> str: + """What git keeps of an edited message: comment lines are stripped + before the commit exists, and everything below the scissors goes.""" + kept = [] + for line in message.splitlines(): + if line.startswith("# ------------------------ >8"): + break + if not line.startswith("#"): + kept.append(line) + return "\n".join(kept) + + def say(message: str, err: bool = False) -> None: print(message, file=sys.stderr if err else sys.stdout, flush=True) @@ -145,25 +183,25 @@ def report(header: str, problems: list[str], where: str = "") -> None: print(f" {problem}", file=sys.stderr) -def subjects_in(rev_range: str) -> list[tuple[str, str]]: - """Subjects in a range, read from the repository you are standing in. +def messages_in(rev_range: str) -> list[tuple[str, str]]: + """Whole messages in a range, read from the repository you are standing in. Not from the repository this script lives in: those are the same thing in normal use, and letting the caller choose means a release script, or a test, can point it at another checkout. """ result = subprocess.run( - ["git", "log", "--no-merges", "--format=%h%x00%s", rev_range], + ["git", "log", "--no-merges", "--format=%h%x00%B%x1e", rev_range], capture_output=True, text=True, ) if result.returncode != 0: print(result.stderr.strip(), file=sys.stderr) sys.exit(2) pairs = [] - for line in result.stdout.splitlines(): - if line.strip(): - sha, _, subject = line.partition("\0") - pairs.append((sha, subject)) + for record in result.stdout.split("\x1e"): + if record.strip(): + sha, _, message = record.strip("\n").partition("\0") + pairs.append((sha, message)) return pairs @@ -174,18 +212,26 @@ def main() -> int: source.add_argument("--message", help="File holding one commit message") source.add_argument("--range", help="Revision range, e.g. origin/main..HEAD") source.add_argument("--title", help="A pull request title") + source.add_argument("--body", help="A pull request description") args = parser.parse_args() failures = 0 if args.message: with open(args.message, encoding="utf-8") as f: - header = f.readline() - problems = check(header) + message = without_comments(f.read()) + header = message.split("\n", 1)[0] + problems = check(header) + attribution(message) if problems: report(header.rstrip(), problems) failures = 1 + elif args.body is not None: + problems = attribution(args.body) + if problems: + report("pull request description", problems) + failures = 1 + elif args.title: problems = check(args.title) if problems: @@ -197,14 +243,15 @@ def main() -> int: # or a bad base leaves nothing to check and the step goes green # having read nothing. Say what was checked, and refuse to pass # on nothing. - pairs = subjects_in(args.range) + pairs = messages_in(args.range) if not pairs: say(f"\n \u2717 no commits in {args.range}", err=True) say(" Nothing was checked. A shallow checkout or a wrong " "base makes this guard pass blind.", err=True) return 1 - for sha, subject in pairs: - problems = check(subject) + for sha, message in pairs: + subject = message.split("\n", 1)[0] + problems = check(subject) + attribution(message) if problems: report(subject, problems, where=sha) failures += 1 @@ -212,7 +259,7 @@ def main() -> int: say(f" {len(pairs)} subject(s) checked, all parse") if failures: - print(f"\n {failures} subject(s) to fix. The rules and the reasoning:" + print(f"\n {failures} message(s) to fix. The rules and the reasoning:" f"\n docs/agent/dev.md, \"Commits: the subject is the changelog\"\n", file=sys.stderr) return 1