Files
chemenu/tools/chemenu/tests/conftest.py
T
torben 23307c3c5f
CI / verify (push) Successful in 55s
Release / release (push) Successful in 38s
fix: Testisolation - kb_dir repointet config.ROOT, lint loest Kollektionen gegen den uebergebenen Baum auf (4.1.1, #44)
Files changed:
- CHANGES.md
- VERSION
- instructions/dev/testing-conventions.md
- tools/chemenu/lint_core.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_hermetic_env.py
- tools/chemenu/tests/test_lint.py
2026-09-03 06:33:38 +02:00

329 lines
14 KiB
Python

import os
import subprocess
from pathlib import Path
import pytest
from chemenu import config, conventions
from chemenu.frontmatter_io import write_page
from chemenu.type_resolver import resolver
# Environment the tool reads for its own behaviour. Cleared for every test, so
# that a test which needs one sets it itself and the rest run against the
# tool's own defaults. `WIKI_TRACE_DIR` is deliberately absent: it is not a
# leak but the redirect `isolated_trace_dir` installs one fixture below.
_WIKITOOL_ENV = (
"WIKI_AUTHOR",
"WIKI_TRACE",
"WIKI_TRACE_CONTENT",
"WIKI_TRACE_MAX_CONTENT",
"WIKITOOL_SESSION_ID",
"WIKITOOL_UPDATE_URL",
"WIKITOOL_UPDATE_TOKEN",
"CHEMENU_ROOT",
)
# Environment git reads for identity or for where its repo lives. A stray
# `GIT_DIR` would point every fixture repo at the developer's checkout; the
# identity variables outrank `git config user.name`, which is the value
# `config.default_author()` is supposed to be reading.
_GIT_ENV = (
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
"EMAIL",
)
def _working_tree_state() -> str | None:
"""`git status --porcelain` for the checkout the tests live in, or None if
there is no git available to ask."""
try:
result = subprocess.run(
["git", "-C", str(config._PACKAGE_ROOT), "status", "--porcelain"],
capture_output=True,
text=True,
timeout=60,
)
except (OSError, subprocess.SubprocessError):
return None
return result.stdout if result.returncode == 0 else None
_TREE_GUARD_MESSAGE = (
"A test wrote into the repository checkout instead of into its tmp_path.\n"
"`git status --porcelain` moved while the suite ran:\n\n"
" before:\n{before}\n"
" after:\n{after}\n\n"
"This is the class of bug Gitea #44 describes: code under test resolves a "
"path through `config.ROOT`/`config.KB_DIR` rather than through the "
"directory the fixture handed it, so the write lands in the real tree. Fix "
"the fixture (repoint `config.ROOT`, as `raw_dir` and `kb_dir` do) or the "
"code path, never the symptom.\n"
"To find the test that did it, re-run with CHEMENU_TREE_GUARD=each - the "
"guard then checks after every test and fails on the first one that moves "
"the tree."
)
@pytest.fixture(scope="session", autouse=True)
def repository_tree_guard():
"""Fail the run if the suite moved a file in the real checkout.
Two `git status` calls for the whole session, which is why this is on by
default: it catches the whole class rather than the one case that was
noticed. It compares before against after rather than demanding a clean
tree, so it says nothing about a developer's own uncommitted work.
It cannot name the culprit - set `CHEMENU_TREE_GUARD=each` for that, which
trades a `git status` per test for a failure on the test that did it.
"""
before = _working_tree_state()
yield
after = _working_tree_state()
if before is None or after is None or before == after:
return
raise AssertionError(
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
)
@pytest.fixture(autouse=True)
def per_test_tree_guard(repository_tree_guard):
"""The bisect half of `repository_tree_guard`, off unless asked for.
`CHEMENU_TREE_GUARD=each` turns the session-wide "something moved the tree"
into "this test moved the tree", at the cost of a `git status` per test.
"""
if os.environ.get("CHEMENU_TREE_GUARD") != "each":
yield
return
before = _working_tree_state()
yield
after = _working_tree_state()
if before is not None and after is not None and before != after:
raise AssertionError(
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
)
@pytest.fixture(autouse=True)
def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Cut every test off from the machine it runs on.
The suite was green for months while silently depending on whoever ran it:
`config.default_author()` shells out to `git config user.name` and got an
answer from the *global* git configuration of the developer's account. The
first CI run that reached pytest had none, and two tests fell over
(Gitea #8); two more of the same kind were written afterwards, by someone
who had read that issue. Neither round was a mistake anyone could have seen
locally - which is the argument for closing the hole here rather than
fixing each case.
So: `HOME` points into `tmp_path`, git's global and system configuration
are `/dev/null`, and the tool's own environment is cleared. A test that
needs an identity now has to establish one - `WIKI_AUTHOR`, or a local
`git config user.name` in its own fixture repo - and one that does not gets
the same empty machine everywhere, CI included.
Returns the fake `HOME`, for the rare test that wants to put something in it.
"""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
# Both are read even when unset in the environment; pointing them at
# /dev/null is git's own documented way to say "there is no such file".
monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull)
monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull)
for name in (*_WIKITOOL_ENV, *_GIT_ENV):
monkeypatch.delenv(name, raising=False)
# The same hole as the environment above, one layer in: `config` resolves
# its paths on access, and `monkeypatch.setattr(config, "KB_DIR", ...)`
# undoes itself by writing the *resolved* old path back as a real
# attribute. That binding outlives the test and hands the next one a
# corpus directory belonging to the previous tree. Cleared on both sides,
# so neither a leak from before nor one from this test can be inherited.
# One layer further in again: `conventions` parses `kb/CONVENTIONS.md` once
# and keys the result on the file's own path and stat, so a repointed
# `KB_DIR` cannot be answered out of it. Cleared here anyway, on both sides,
# for the same reason `config.reset()` is - a fixture that leaves state
# behind is the hole this file exists to close, and the cost of proving it
# cannot leak is one function call per test.
config.reset()
conventions.reset_cache()
yield home
config.reset()
conventions.reset_cache()
def use_shipped_type_specs(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep the shipped `types/` reachable for a test that repoints `ROOT`.
`TYPES_DIR` and the `TypeResolver`'s own root both follow `ROOT` now, which
is the whole point of making the resolution lazy - but it means a fixture
tree without a `types/` resolves no page kind at all, and a check that
depends on a page being a `source` silently stops finding one. These tests
do want the real schema: a synthetic type-spec would prove the command
works against a fixture rather than against what it ships with.
So the dependency is declared instead of inherited. While both were bound at
import time it held by accident, which is the same shape as the hole
`raw_dir` was written to close, one layer down.
"""
monkeypatch.setattr(config, "TYPES_DIR", config._PACKAGE_ROOT / "types")
monkeypatch.setattr(resolver, "_repo_root", config._PACKAGE_ROOT)
@pytest.fixture(autouse=True)
def isolated_trace_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, hermetic_environment: Path
) -> Path:
"""Send every test's telemetry into its own tmp_path.
The emitter is wired into `cli.main()` and into both gates, so any test that
exercises those paths writes a trace. Without this the suite appends to the
real `reports/telemetry/` - which is exactly what it did for one run before
this fixture existed.
Depends on `hermetic_environment` for the ordering, not for a value: that
fixture clears `WIKI_TRACE`, so it has to run first for the redirect
installed here to survive with tracing still enabled. Tracing is never
turned off suite-wide - two telemetry tests assert that a trace is written.
"""
trace_dir = tmp_path / "telemetry"
monkeypatch.setenv("WIKI_TRACE_DIR", str(trace_dir))
return trace_dir
@pytest.fixture
def raw_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A small fake raw/ tree sitting next to the kb_dir fixture (same
tmp_path), for provenance-coverage tests. One file is deliberately left
uncovered by any source page.
`config.ROOT` is repointed at `tmp_path` for the same reason
`hermetic_environment` clears the environment: a `raw/...` path in a
fixture page is resolved against the *repository* root by the code under
test (`provenance.legacy_source_pages`, for one), so without this the
fixture builds a raw tree that the code never looks at and answers from
the developer's own `raw/` instead.
That was not theoretical. `test_legacy_source_pages_flags_url_and_directory`
passed for months only because this checkout happened to have a
`raw/documents/` directory; the run that emptied it turned the test red in
CI while it stayed green locally, because git does not track empty
directories and the local one survived. Same shape as Gitea #8, closed the
same way - in the fixture, not in the one test that happened to trip.
"""
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
raw = tmp_path / "raw"
(raw / "notes").mkdir(parents=True)
(raw / "notes" / "Aurora.md").write_text("# Aurora raw notes\n", encoding="utf-8")
(raw / "notes" / "Uningested.md").write_text("# Not yet ingested anywhere\n", encoding="utf-8")
return raw
@pytest.fixture
def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A minimal fixture kb/ with the standard collection layout, populated
with a handful of pages covering entities/concepts/sources/comparisons.
Every collection carries a COLLECTION.md, both because that is what makes it
a collection and because the scanner must prove it skips them at a depth the
kb-root meta files never reach.
`config.ROOT` is repointed for the same reason `raw_dir` does it, one
collection over: code under test that resolves a path through
`config.ROOT`/`config.KB_DIR` rather than through the directory it was
handed otherwise reaches the *real* repository. That was not theoretical
either - a test calling `kb_state.write_kb_state()` overwrote this
checkout's `.wikitool-kb.json`, and `lint`'s collection lookup answered
"no collection" for every fixture page, which left `unauthorised_labels`
with no working test at all (Gitea #44).
"""
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
kb = tmp_path / "kb"
for sub in ("entities/projects", "entities/systems", "entities/tools",
"entities/technologies", "entities/people",
"concepts", "sources", "comparisons"):
(kb / sub).mkdir(parents=True)
# The contracts carry a real declaration, because three things now read one:
# `docs verify` checks `profile:`/`required_by_stack:`, and `xref add` asks
# `outbound:` whether a label is authorised from this collection. A fixture
# contract without it would make every `xref add` in the suite fail for a
# reason that has nothing to do with what the test is about.
for collection in ("entities", "concepts", "sources", "comparisons"):
(kb / collection / "COLLECTION.md").write_text(
"---\n"
f"profile: {collection}\n"
f"required_by_stack: {'true' if collection == 'sources' else 'false'}\n"
"outbound:\n"
" any: [depends-on, required-by, runs-on, hosts, uses, implements, see-also]\n"
"---\n\n"
f"# kb/{collection}/ - Collection Contract\n",
encoding="utf-8",
)
write_page(
kb / "entities/systems/aurora.md",
{
"type": "types/entity.md", "entity_type": "system",
"tags": ["server"], "created": "2026-07-31", "modified": "2026-07-31",
"related": ["Borealis"], "sources": [], "confidence": 0.9,
"summary": "Server hosting DocStore with ZFS storage",
},
"\n# aurora\n\n## Description\n\nHosts things.\n\n## Relationships\n\n- **Related to:** [[Borealis]]\n\n## See Also\n\n- [[Borealis]]\n",
)
write_page(
kb / "entities/systems/Borealis.md",
{
"type": "types/entity.md", "entity_type": "system",
"tags": ["workstation"], "created": "2026-08-02", "modified": "2026-08-02",
"related": ["aurora"], "sources": [], "confidence": 0.9,
},
"\n# Borealis\n\n## Description\n\nA workstation.\n\n## Relationships\n\n- **Related to:** [[aurora]]\n",
)
write_page(
kb / "entities/tools/gdeploy.md",
{
"type": "types/entity.md", "entity_type": "tool",
"tags": [], "created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.8,
},
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
)
write_page(
kb / "concepts/Modbus.md",
{
"type": "types/concept.md", "concept_type": "protocol",
"tags": [], "created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.7,
},
"\n# Modbus\n\n## Definition\n\nIndustrial protocol.\n",
)
write_page(
kb / "sources/Source - Aurora.md",
{
"type": "types/source.md", "source_type": "notes", "author": "Torben",
"source": "raw/notes/Aurora.md", "date": "2026-08-02",
"tags": [], "entities": ["aurora"], "concepts": [],
},
"\n# Source: Aurora\n\n## Summary\n\nNotes.\n",
)
(kb / "index.md").write_text(
"# Wiki Index\n\n[[aurora]] [[Borealis]] [[gdeploy]] [[Modbus]] [[Source - Aurora]]\n",
encoding="utf-8",
)
(kb / "log.md").write_text("# Wiki Log\n", encoding="utf-8")
return kb