09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
245 lines
8.8 KiB
Python
245 lines
8.8 KiB
Python
"""pytester regression test for the integration tier-enforcement hook.
|
|
|
|
``tests/integration/conftest.py`` registers a ``pytest_collection_modifyitems``
|
|
hook that REFUSES to collect a test under ``tests/integration/`` unless it is
|
|
VCR-tier (``@pytest.mark.vcr``, ``@notebooklm_vcr.use_cassette``, or the
|
|
explicit ``@pytest.mark.allow_no_vcr`` opt-out).
|
|
|
|
This module is the **durable, committed** regression test for that hook —
|
|
spelled out via ``pytester`` so the assertion lives in a real test file rather
|
|
than the antipattern of "add a synthetic file to ``tests/integration/`` then
|
|
delete it." If the hook is ever weakened (e.g. someone changes ``raise`` to a
|
|
warning, or accidentally skips violations) these tests fail in CI.
|
|
|
|
Three scenarios are pinned:
|
|
|
|
1. **Violation rejected** — a fresh test file under ``tests/integration/`` with
|
|
no VCR marker, no ``use_cassette``, and no opt-out must cause pytest to exit
|
|
non-zero with ``UsageError`` in stderr.
|
|
2. **``allow_no_vcr`` opt-out honored** — the same file with
|
|
``pytestmark = pytest.mark.allow_no_vcr`` must collect and run cleanly.
|
|
3. **Non-integration paths are not gated** — a violating file under
|
|
``tests/unit/`` (i.e. without any VCR marker) must collect and run cleanly;
|
|
the hook only governs ``tests/integration/``.
|
|
|
|
Each scenario builds a self-contained temp project via ``pytester`` and inlines
|
|
a faithful copy of the hook so the regression test does NOT depend on importing
|
|
the real conftest (which would also drag in unrelated infrastructure). The
|
|
inlined hook is kept in lockstep with the real one — if the real hook's
|
|
detection logic changes, update ``HOOK_SOURCE`` here too.
|
|
|
|
The scenarios run via ``runpytest_subprocess`` (not in-process ``runpytest``):
|
|
the synthetic project mirrors the real ``tests/`` package layout (with
|
|
``__init__.py`` files), so an in-process run would collide with the parent
|
|
session's already-imported ``tests.*`` modules in ``sys.modules`` and fail
|
|
collection with ``ModuleNotFoundError``. A fresh subprocess isolates the
|
|
synthetic ``tests`` package from the real one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import textwrap
|
|
|
|
import pytest
|
|
|
|
pytest_plugins = ["pytester"]
|
|
|
|
|
|
# Faithful copy of the hook + helper from
|
|
# ``tests/integration/conftest.py``. Kept inline so ``pytester`` can drop it
|
|
# into a synthetic conftest without importing the real one (which carries
|
|
# unrelated keepalive-fixture code that's not under test here). If the
|
|
# production hook's detection logic changes, update this copy.
|
|
HOOK_SOURCE = textwrap.dedent(
|
|
"""
|
|
import pytest
|
|
|
|
|
|
def _has_use_cassette_decorator(item):
|
|
func = getattr(item, "function", None)
|
|
seen = set()
|
|
while func is not None and id(func) not in seen:
|
|
seen.add(id(func))
|
|
wrapper = getattr(func, "_self_wrapper", None)
|
|
if wrapper is not None:
|
|
owner = getattr(wrapper, "__self__", None)
|
|
if owner is not None and type(owner).__name__ == "CassetteContextDecorator":
|
|
return True
|
|
func = getattr(func, "__wrapped__", None)
|
|
return False
|
|
|
|
|
|
def pytest_collection_modifyitems(config, items):
|
|
violations = []
|
|
for item in items:
|
|
nodeid = item.nodeid
|
|
if not nodeid.startswith("tests/integration/"):
|
|
continue
|
|
if item.get_closest_marker("vcr") is not None:
|
|
continue
|
|
if item.get_closest_marker("allow_no_vcr") is not None:
|
|
continue
|
|
if _has_use_cassette_decorator(item):
|
|
continue
|
|
violations.append(nodeid)
|
|
if violations:
|
|
joined = "\\n ".join(violations)
|
|
raise pytest.UsageError(
|
|
"tests/integration/ tests must be VCR-tier. Add "
|
|
"@pytest.mark.vcr, @notebooklm_vcr.use_cassette, or - for "
|
|
"mock-only tests - @pytest.mark.allow_no_vcr. Violations:\\n "
|
|
+ joined
|
|
)
|
|
"""
|
|
).strip()
|
|
|
|
|
|
MARKER_REGISTRATION = textwrap.dedent(
|
|
"""
|
|
[pytest]
|
|
asyncio_default_fixture_loop_scope = function
|
|
markers =
|
|
vcr: vcr-tier
|
|
allow_no_vcr: opt out
|
|
"""
|
|
).strip()
|
|
|
|
|
|
def _scaffold(pytester: pytest.Pytester, integration_body: str) -> None:
|
|
"""Build a synthetic project mirroring real layout (incl. the hook)."""
|
|
pytester.makeini(MARKER_REGISTRATION)
|
|
pytester.makepyfile(
|
|
**{
|
|
"tests/integration/conftest.py": HOOK_SOURCE,
|
|
"tests/integration/test_under_test.py": integration_body,
|
|
}
|
|
)
|
|
|
|
|
|
def test_violation_rejected(pytester: pytest.Pytester) -> None:
|
|
"""Bare integration test (no marker, no cassette, no opt-out) → UsageError."""
|
|
_scaffold(
|
|
pytester,
|
|
textwrap.dedent(
|
|
"""
|
|
def test_no_marker_no_cassette_no_optout():
|
|
assert True
|
|
"""
|
|
).strip(),
|
|
)
|
|
result = pytester.runpytest_subprocess("tests/integration/")
|
|
# ``UsageError`` from a collection hook ends the run with exit code != 0
|
|
# and the message printed to stderr.
|
|
assert result.ret != 0
|
|
combined = result.stderr.str() + result.stdout.str()
|
|
assert "UsageError" in combined or "tests/integration/ tests must be VCR-tier" in combined
|
|
assert "test_under_test.py::test_no_marker_no_cassette_no_optout" in combined
|
|
|
|
|
|
def test_allow_no_vcr_optout_honored(pytester: pytest.Pytester) -> None:
|
|
"""``pytestmark = pytest.mark.allow_no_vcr`` lets a mock-only test through."""
|
|
_scaffold(
|
|
pytester,
|
|
textwrap.dedent(
|
|
"""
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.allow_no_vcr
|
|
|
|
|
|
def test_mock_only():
|
|
assert True
|
|
"""
|
|
).strip(),
|
|
)
|
|
result = pytester.runpytest_subprocess("tests/integration/")
|
|
assert result.ret == 0
|
|
result.assert_outcomes(passed=1)
|
|
|
|
|
|
def test_vcr_marker_honored(pytester: pytest.Pytester) -> None:
|
|
"""``@pytest.mark.vcr`` satisfies the hook without an opt-out."""
|
|
_scaffold(
|
|
pytester,
|
|
textwrap.dedent(
|
|
"""
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.vcr
|
|
|
|
|
|
def test_vcr_tier():
|
|
assert True
|
|
"""
|
|
).strip(),
|
|
)
|
|
result = pytester.runpytest_subprocess("tests/integration/")
|
|
assert result.ret == 0
|
|
result.assert_outcomes(passed=1)
|
|
|
|
|
|
def test_use_cassette_decorator_honored(pytester: pytest.Pytester) -> None:
|
|
"""``@notebooklm_vcr.use_cassette(...)`` alone (no ``vcr`` marker) passes.
|
|
|
|
Exercises ``_has_use_cassette_decorator`` end-to-end against a real
|
|
``vcr.VCR().use_cassette`` decorator (which wraps the test in a
|
|
``wrapt.FunctionWrapper`` whose ``_self_wrapper`` is a bound method on a
|
|
``CassetteContextDecorator``). This is the wrapper-introspection branch
|
|
of the hook that the marker checks alone cannot exercise.
|
|
"""
|
|
pytester.makeini(MARKER_REGISTRATION)
|
|
pytester.makepyfile(
|
|
**{
|
|
"tests/integration/conftest.py": HOOK_SOURCE,
|
|
"tests/integration/test_with_use_cassette.py": textwrap.dedent(
|
|
"""
|
|
import os
|
|
import tempfile
|
|
|
|
import vcr
|
|
|
|
# Point at a writable empty directory so vcr doesn't try to
|
|
# load a cassette that doesn't exist. record_mode='none'
|
|
# would refuse the request — but the test body makes no HTTP
|
|
# call so the cassette is never opened anyway.
|
|
_cassette_dir = tempfile.mkdtemp()
|
|
_vcr = vcr.VCR(cassette_library_dir=_cassette_dir)
|
|
|
|
|
|
@_vcr.use_cassette("none.yaml")
|
|
def test_decorated_only():
|
|
# No HTTP call — we only need the hook to see the
|
|
# CassetteContextDecorator wrapper on the function.
|
|
assert True
|
|
"""
|
|
).strip(),
|
|
}
|
|
)
|
|
result = pytester.runpytest_subprocess("tests/integration/")
|
|
assert result.ret == 0
|
|
result.assert_outcomes(passed=1)
|
|
|
|
|
|
def test_unit_tier_not_gated(pytester: pytest.Pytester) -> None:
|
|
"""The hook ONLY governs ``tests/integration/`` — unit-tier is untouched.
|
|
|
|
Builds a violating file under ``tests/unit/`` (no marker, no cassette, no
|
|
opt-out) and asserts the run still passes. Guards against the hook
|
|
accidentally over-reaching.
|
|
"""
|
|
pytester.makeini(MARKER_REGISTRATION)
|
|
pytester.makepyfile(
|
|
**{
|
|
"tests/integration/conftest.py": HOOK_SOURCE,
|
|
"tests/unit/test_bare.py": textwrap.dedent(
|
|
"""
|
|
def test_unit_no_marker():
|
|
assert True
|
|
"""
|
|
).strip(),
|
|
}
|
|
)
|
|
result = pytester.runpytest_subprocess("tests/unit/")
|
|
assert result.ret == 0
|
|
result.assert_outcomes(passed=1)
|