cli_vcr — CLI integration tests over VCR cassettes
These tests run the real CLI → Client → RPC path, but VCR replays the HTTP
traffic from tests/cassettes/*.yaml instead of hitting the live NotebookLM
service. They are the integration tier between pure unit tests (no network) and
e2e tests (real API, real auth).
How cassette matching actually works
The default VCR matcher (tests/vcr_config.py) matches on:
- HTTP method, scheme, host, port, and path,
- the RPC method id (
rpcids=in thebatchexecuteURL), and - the decoded request body shape (
freqmatcher).
It does not match on the notebook id, source id, or any other id in the
request. The cassette corpus changes over time; do not pin docs or tests to a
literal cassette count or notebook count. mock_context injects a single
notebook id (PLACEHOLDER_NOTEBOOK_ID, c3f6285f…) for most tests, and replay
still succeeds because ids are scrubbed/decorative for matcher purposes.
Consequence: the ids are decorative
The placeholder ids in _fixtures.py are arbitrary. The only
thing that matters about an id on the command line is its shape — a full
36-char UUID where the CLI's resolver expects one, so it short-circuits the
LIST_NOTEBOOKS / LIST_SOURCES preflight (which the single-purpose cassettes
do not record). The specific value is never compared against the recorded
request.
There is therefore no canonical-fixture registry and no cassette-membership guard — they would police a relationship that does not exist.
The one place a placeholder is load-bearing is the input-echo assertion: a
mutation command threads the id the test passed into its own --json output, so
output["notebook_id"] == MUTATION_NOTEBOOK_ID holds for any cassette.
MUTATION_NOTEBOOK_ID is present in zero cassettes on purpose, which proves
the echoed value comes from the input.
Re-record-safe assertion tiers
Every assertion must survive a re-record that uses a different notebook with different data. The allowed vocabulary:
- Schema — the
--jsonenvelope field names + types (assert_json_envelope(result, schema=...), schemas defined inconftest.py). - Invariants — each id is UUID-shaped, each title is non-empty,
count > 0. - Cross-render — text vs
--jsonof the same cassette agree on row count / id set. - Filter correctness — e.g.
artifact list --type audio⇒ every item is audio (proves decoder logic, not a recorded value).test_sources.pyandtest_artifacts.pyalso use_cassette_expectations.pyto derive expected projections from the same cassette payload. - Input-echo — a mutation's output id
==the placeholder the test passed.
❌ Never pin a value that came from the recorded response: a recorded title, a server-returned id, or a literal count. Exact count equality is fine only when the expected value is derived from the same cassette projection or the same JSON array length.
test_sources.py is the worked template for tiers 1–3 + 5;
test_artifacts.py::TestArtifactListByType covers tier 4.
Re-recording
Cassettes replay at record_mode="none" by default. Setting
NOTEBOOKLM_VCR_RECORD=1 switches VCR to record_mode="new_episodes": existing
matching episodes replay, and only missing episodes are appended. To truly
re-record, delete or move the target cassette first, then run (maintainer, with
a valid local profile):
NOTEBOOKLM_VCR_RECORD=1 uv run pytest tests/integration/cli_vcr/<file>.py -m vcr
Record against whatever notebook is handy — the assertions are
notebook-agnostic, so a fresh cassette recorded against a different notebook
drops in without any fixture surgery. Some command families (settings,
profile, doctor) pin NOTEBOOKLM_HOME or local profile state in their tests;
they are not always auto-recordable through the normal live-profile path.
You only need to touch the tests when the response shape changes (a new
field, a renamed field, a changed type). In that case update the affected schema
constant in conftest.py — the shape change is a real signal worth catching.
You should not need to update the placeholder ids in _fixtures.py or the
per-test assertions just because the underlying notebook changed.
Layout
| File | Purpose |
|---|---|
_fixtures.py |
Flat set of decorative placeholder ids + back-compat aliases. _-prefixed + non-test_* so the cross-test-import gate (#1445) permits tests to import it. |
conftest.py |
Shared fixtures, assert_json_envelope, and the per-family *_SCHEMA constants. |
test_*.py |
One module per CLI command group. |