Files
teng-lin--notebooklm-py/tests/vcr_config.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

808 lines
36 KiB
Python

"""VCR.py configuration for recording and replaying HTTP interactions.
This module provides VCR.py configuration for deterministic, offline testing
against recorded API responses. Use this when you want to:
1. Record real API interactions during development
2. Create regression tests from actual API responses
3. Run tests without network access or rate limits
Usage:
from tests.vcr_config import notebooklm_vcr
@notebooklm_vcr.use_cassette('my_test.yaml')
async def test_something():
async with NotebookLMClient(auth) as client:
result = await client.notebooks.list()
Recording new cassettes:
1. Set NOTEBOOKLM_VCR_RECORD=1 (or =true, =yes)
2. Run the test with valid authentication
3. Cassette is saved to tests/cassettes/
4. Verify sensitive data is scrubbed before committing
CI Strategy:
- PR checks: Use cassettes (fast, deterministic, no auth needed)
- Nightly: Run with real API to detect drift (NOTEBOOKLM_VCR_RECORD=1)
When to use VCR vs pytest-httpx:
- pytest-httpx: Crafted test responses for specific scenarios
- VCR.py: Recorded real responses for regression testing
Sanitization
------------
Scrub patterns and the byte-count re-derivation helper both live in
:mod:`tests.cassette_patterns`. This module deliberately holds
NO regex literals so we can never drift between "what the recorder scrubs" and
"what the cassette guard inspects". :func:`scrub_request` / :func:`scrub_response`
here are thin wrappers that delegate to
:func:`tests.cassette_patterns.scrub_string` and
:func:`tests.cassette_patterns.recompute_chunk_prefix`.
Keepalive-poke disable
------------------------------
Every test that carries ``@pytest.mark.vcr`` (directly or via a module-level
``pytestmark``) automatically runs with
``NOTEBOOKLM_DISABLE_KEEPALIVE_POKE=1`` via the
``_disable_keepalive_poke_for_vcr`` autouse fixture in
:mod:`tests.integration.conftest`. This silences the layer-1
``accounts.google.com/RotateCookies`` keepalive — none of the cassettes
recorded before that poke landed contain it, so leaving it enabled would
produce a guaranteed cassette mismatch on every replay.
If you need a VCR test that actually captures or asserts on ``RotateCookies``
traffic (e.g. a future cassette recording the keepalive itself), opt out with
the ``@pytest.mark.no_keepalive_disable`` marker — the autouse fixture will
leave the env var alone and let the poke fire.
"""
import importlib.util
import json
import os
import re
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
import vcr
def _load_sibling(module_name: str, file_name: str) -> Any:
"""Load a sibling module under ``tests/`` by file path.
``from tests.cassette_patterns import ...`` now resolves inside pytest via
``pythonpath = ["."]`` (pyproject, #1482); loading by file path is kept as a
``sys.path``-independent fallback so this module also works when imported
outside pytest, the same idiom ``tests/unit/test_cookie_redaction.py`` uses
to import this very file.
"""
spec = importlib.util.spec_from_file_location(
module_name, Path(__file__).resolve().parent / file_name
)
if spec is None or spec.loader is None: # pragma: no cover - import wiring guard
raise ImportError(f"Could not load {file_name} next to vcr_config.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_cassette_patterns = _load_sibling("tests_cassette_patterns", "cassette_patterns.py")
recompute_chunk_prefix = _cassette_patterns.recompute_chunk_prefix
scrub_string = _cassette_patterns.scrub_string
scrub_cookie_header = _cassette_patterns.scrub_cookie_header
scrub_set_cookie = _cassette_patterns.scrub_set_cookie
build_synthetic_error_response = _cassette_patterns.build_synthetic_error_response
synthetic_error_cassette_name = _cassette_patterns.synthetic_error_cassette_name
SYNTHETIC_ERROR_CASSETTE_PREFIX = _cassette_patterns.SYNTHETIC_ERROR_CASSETTE_PREFIX
VALID_ERROR_MODES = _cassette_patterns.VALID_ERROR_MODES
# env var name shared with :mod:`notebooklm._error_injection`. Kept in
# sync as a local copy so the VCR-only replay path (which does not import
# :mod:`notebooklm._error_injection`) can still parse the env var without
# dragging the production module in. Unit tests in
# ``tests/unit/test_vcr_config.py`` import ``ERROR_INJECT_ENV_VAR`` directly
# from the canonical home — this duplication covers ONLY the VCR-replay
# path, not the unit-test path.
ERROR_INJECT_ENV_VAR = "NOTEBOOKLM_VCR_RECORD_ERRORS"
def _is_vcr_record_mode() -> bool:
"""Return True if VCR record mode is enabled via environment.
Reads ``NOTEBOOKLM_VCR_RECORD`` and treats the case-insensitive values
``"1"``, ``"true"``, and ``"yes"`` as enabling record mode. Any other
value (including unset/empty) returns False.
Single source of truth for record-mode env-var parsing — both this
module's VCR-instance config and ``tests/integration/conftest.py``
consume this helper to avoid drift between the two checks.
"""
return os.environ.get("NOTEBOOKLM_VCR_RECORD", "").casefold() in ("1", "true", "yes")
def get_error_injection_mode() -> str | None:
"""Return the active synthetic-error mode from the environment, or ``None``.
Reads ``NOTEBOOKLM_VCR_RECORD_ERRORS`` and validates the value against
:data:`VALID_ERROR_MODES`. Unset, empty, or unrecognized values resolve to
``None`` so plumbing never crashes on a typo — the unit tests assert the
typo path explicitly. The value comparison is case-insensitive.
This helper mirrors ``_get_error_injection_mode`` in
:mod:`notebooklm._error_injection`; both sides validate against the
same canonical set in :mod:`tests.cassette_patterns` so they cannot
drift.
"""
# ``.casefold()`` (not ``.lower()``) for parity with ``_is_vcr_record_mode``
# and the project's Unicode-aware case-insensitive rule — ASCII-identical
# for VALID_ERROR_MODES, so this is consistency hygiene (#1268).
raw = os.environ.get(ERROR_INJECT_ENV_VAR, "").strip().casefold()
if not raw:
return None
return raw if raw in VALID_ERROR_MODES else None
def scrub_request(request: Any) -> Any:
"""Scrub sensitive data from recorded HTTP request.
Handles:
- Cookie headers
- URL query parameters (session IDs)
- Request body (CSRF tokens)
"""
# Scrub Cookie header. ``scrub_string`` runs first for the name-anchored
# session-cookie / auth-token patterns and the JSON-shape coverage; then
# ``scrub_cookie_header`` makes a NAME-AGNOSTIC pass that clears EVERY
# remaining cookie pair's value (the ``_ga`` / ``_gcl_au`` / ``AEC`` class
# that is not on the cookie allowlist). Both are idempotent, so the order
# is safe and the name-agnostic pass adds coverage without weakening the
# existing scrubbing.
if "Cookie" in request.headers:
request.headers["Cookie"] = scrub_cookie_header(scrub_string(request.headers["Cookie"]))
# Scrub URL (contains f.sid session parameter)
if request.uri:
request.uri = scrub_string(request.uri)
# Scrub request body (contains at= CSRF token)
if request.body:
if isinstance(request.body, bytes):
try:
decoded = request.body.decode("utf-8")
request.body = scrub_string(decoded).encode("utf-8")
except UnicodeDecodeError:
pass # Binary content, skip scrubbing
else:
request.body = scrub_string(request.body)
return request
def _substitute_synthetic_error(response: dict[str, Any]) -> dict[str, Any]:
"""defense-in-depth synthetic-error substitution.
When ``NOTEBOOKLM_VCR_RECORD_ERRORS`` resolves to a valid mode (see
:data:`VALID_ERROR_MODES`), rewrite the response shape to the canonical
synthetic-error shape from :mod:`tests.cassette_patterns`.
The error-injection middleware in
:mod:`notebooklm._middleware.error_injection` already substitutes
the live response BEFORE it reaches VCR, so in normal recording this hook
sees the synthetic shape already. This pass exists so that:
1. Tests that bypass the production transport (e.g. direct
``notebooklm_vcr.use_cassette`` with a hand-built ``httpx.AsyncClient``)
still record synthetic shapes when the env var is set.
2. The substitution is observable from cassette-only paths in CI without
requiring access to the production transport.
Returns ``response`` unchanged when the env var is unset or the value is
not a recognized mode.
"""
mode = get_error_injection_mode()
if mode is None:
return response
status_code, body_bytes, headers = build_synthetic_error_response(mode)
response["status"] = {"code": status_code, "message": ""}
response["body"] = {"string": body_bytes}
# Preserve any incoming headers (e.g. Content-Length VCR fills in) but
# overlay our synthetic ones so the Content-Type / Retry-After hints land
# on the recorded shape.
out_headers = response.get("headers", {})
if not isinstance(out_headers, dict):
out_headers = {}
for k, v in headers.items():
out_headers[k] = [v]
response["headers"] = out_headers
return response
def _ci_header_keys(headers: dict[str, Any], name: str) -> list[str]:
"""Return ALL keys in ``headers`` matching ``name`` case-insensitively.
Google serves HTTP/2, whose header names are lowercase (``set-cookie``),
while older cassettes were recorded with title-case (``Set-Cookie``). A
case-sensitive ``name in headers`` check misses the lowercase form and
silently leaves live ``Set-Cookie`` session tokens unscrubbed — the
``test_cassette_shapes`` guard would catch the leak, but the scrubber must
prevent it, not merely rely on detection. Returns *every* matching key (not
just the first) so a dict carrying both ``Set-Cookie`` and ``set-cookie``
gets every token-bearing entry scrubbed, not one.
"""
lowered = name.lower()
return [key for key in headers if key.lower() == lowered]
def scrub_response(response: dict[str, Any]) -> dict[str, Any]:
"""Scrub sensitive data from recorded HTTP response.
Handles:
- Response body (may contain tokens in JSON or echoed headers)
- Response headers (Set-Cookie headers may contain session tokens)
- Both string and bytes response bodies
After string scrubbing runs, ``recompute_chunk_prefix`` is invoked on the
body to re-derive the ``<count>\\n<payload>\\n`` byte-count prefixes used
by Google's chunked batchexecute responses. Scrubbing frequently changes
payload length (e.g. ``21_digit_account_id`` -> ``SCRUBBED_USER_ID``); if
we left the original counts in place the cassette would fail the byte-count
assertion in ``tests/_guardrails/test_cassette_shapes.py`` and the decoder's
tolerance branch would log a warning on every replay. The helper is a
no-op on bodies that don't look chunked, so it's safe to call
unconditionally.
Synthetic-error recording: when ``NOTEBOOKLM_VCR_RECORD_ERRORS`` is set to a valid mode,
:func:`_substitute_synthetic_error` runs FIRST so that downstream scrub
steps see the canonical synthetic shape rather than whatever the wire
produced (the error-injection middleware in
:mod:`notebooklm._middleware.error_injection` normally already
substituted, but this pass closes the loop for VCR-only test paths).
"""
# synthetic-error substitution (no-op when env var unset).
response = _substitute_synthetic_error(response)
# Scrub response body
body = response.get("body", {})
if "string" in body:
content = body["string"]
if isinstance(content, bytes):
try:
decoded = content.decode("utf-8")
scrubbed = scrub_string(decoded)
# Re-derive chunk byte-counts after scrubbing.
rederived = recompute_chunk_prefix(scrubbed)
body["string"] = rederived.encode("utf-8")
except UnicodeDecodeError:
pass # Binary content (audio, images), skip scrubbing
else:
scrubbed = scrub_string(content)
# Re-derive chunk byte-counts after scrubbing.
rederived = recompute_chunk_prefix(scrubbed)
body["string"] = rederived
# Scrub Set-Cookie headers (may contain session tokens). ``scrub_string``
# runs first for the name-anchored / auth-token patterns; then
# ``scrub_set_cookie`` makes a NAME-AGNOSTIC pass that clears the leading
# cookie pair's value while preserving the cookie attributes (Path / Domain
# / Expires / Secure / HttpOnly / ...). Both passes are idempotent.
headers = response.get("headers", {})
for cookie_key in _ci_header_keys(headers, "Set-Cookie"):
cookies = headers[cookie_key]
if isinstance(cookies, list):
headers[cookie_key] = [scrub_set_cookie(scrub_string(c)) for c in cookies]
elif isinstance(cookies, str):
headers[cookie_key] = scrub_set_cookie(scrub_string(cookies))
# Scrub resumable-upload session tokens echoed in response headers. The
# ``X-Goog-Upload-(Control-)URL`` values embed ``upload_id=<token>`` (handled
# by ``scrub_string``'s upload_id pattern); ``X-GUploader-UploadID`` carries a
# bare token with no anchor, so its value is replaced wholesale. Without this
# a fresh recording leaks per-upload tokens past ``scrub_response`` (the guard
# catches them, but the scrubber should prevent them — not just detect).
for _name in ("X-Goog-Upload-URL", "X-Goog-Upload-Control-URL"):
for _key in _ci_header_keys(headers, _name):
_vals = headers[_key]
headers[_key] = (
[scrub_string(v) for v in _vals] if isinstance(_vals, list) else scrub_string(_vals)
)
for _uploader_key in _ci_header_keys(headers, "X-GUploader-UploadID"):
_vals = headers[_uploader_key]
headers[_uploader_key] = (
["SCRUBBED_UPLOAD_ID" for _ in _vals]
if isinstance(_vals, list)
else "SCRUBBED_UPLOAD_ID"
)
return response
# =============================================================================
# Custom VCR Matchers
# =============================================================================
def _rpcids_matcher(r1, r2):
"""Match requests by the ``rpcids`` query parameter.
All batchexecute POST requests share the same URL path. Without this
matcher VCR relies on sequential play-count ordering which is fragile
(breaks on Windows CI). Comparing ``rpcids`` makes matching deterministic.
"""
qs1 = parse_qs(urlparse(r1.uri).query)
qs2 = parse_qs(urlparse(r2.uri).query)
assert qs1.get("rpcids") == qs2.get("rpcids")
# Volatile keys that the matcher recursively strips from any dict-shaped node
# inside the decoded ``f.req`` payload before comparison. These are per-request
# values that the server generates fresh on each call (timestamps, request IDs,
# nonces) and which would otherwise cause every replay to fail the matcher.
#
# Kept as a frozenset so the membership test stays O(1) and the value is
# trivially copyable into the test suite for the fallback-path assertions.
# Lowercase comparison is performed in :func:`_strip_volatile` so synonyms like
# ``RequestId`` / ``request_id`` / ``requestID`` all hit the same entry.
_FREQ_VOLATILE_KEYS: frozenset[str] = frozenset(
{
"timestamp",
"clienttimestamp",
"servertimestamp",
"requestid",
"request_id",
"_reqid",
"reqid",
"nonce",
"clientnonce",
}
)
# UUID v4 placeholder used by :func:`_strip_volatile` to fold session-drift
# UUIDs (notebook IDs, source IDs, project IDs) onto a canonical value. The
# matcher's intent is to catch **structural** drift (different RPC id, different
# arg shape, different non-UUID values) — but in practice cassettes are recorded
# against one notebook UUID and replayed against a different one with the same
# structural shape. Treating UUID-shaped leaves as effectively volatile (per the
# spec's "widen the volatile-key scrub list" escape hatch in P1-3) keeps the
# matcher robust across recording sessions while still failing on meaningful
# drift like RPC id changes or non-UUID arg drift.
#
# The placeholder string carries the same length as a real UUID v4 so any
# byte-count assertions downstream stay accurate. ``_NORMALIZED`` is not a
# substring of any cassette UUID we've ever recorded — chosen so a search for
# this placeholder in a cassette flags only the normalization, not real data.
_UUID_PLACEHOLDER = "00000000-0000-0000-0000-_NORMALIZED"
# Canonical UUID v4 string regex. Matches the 8-4-4-4-12 hex layout used by
# every UUID NotebookLM emits (notebook IDs, source IDs, artifact IDs, project
# IDs, conversation IDs all share this shape). Anchored to word boundaries so a
# UUID embedded in a larger string still normalizes but a hex string of similar
# length (e.g. a session token) does not accidentally match.
_UUID_RE = re.compile(
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
)
def _normalize_uuids(value: str) -> str:
"""Replace every UUID v4 substring in ``value`` with :data:`_UUID_PLACEHOLDER`.
Used by :func:`_strip_volatile` on string leaves so two requests carrying
different but structurally-equivalent UUIDs still match. See the
:data:`_UUID_PLACEHOLDER` docstring for the rationale on why UUIDs are
treated as functionally volatile by this matcher.
Empty strings are also folded onto the placeholder so a cassette
recorded with a notebook UUID still matches a replay where no fixture
UUID was bound to the env (the live request would send an empty-string
identifier in that slot). This is the same "incidental drift, not a
meaningful mismatch" reasoning the UUID normalization itself uses.
"""
if value == "":
return _UUID_PLACEHOLDER
return _UUID_RE.sub(_UUID_PLACEHOLDER, value)
def _strip_volatile(node: Any) -> Any:
"""Return a normalized copy of ``node`` ready for matcher comparison.
Three transformations run recursively over the decoded ``f.req`` payload:
1. **Drop volatile dict keys** — any key whose lowercased name is in
:data:`_FREQ_VOLATILE_KEYS` is removed entirely (``timestamp``,
``requestId``, ``nonce``, ...).
2. **Normalize UUID and empty-string leaves** — every UUID v4 substring
and every empty string is replaced with :data:`_UUID_PLACEHOLDER` so
notebook / source / artifact UUIDs that drift across recording
sessions still match (this is the spec's "widen the volatile-key
scrub list" escape hatch from P1-3).
3. **Preserve everything else** — lists keep their order, non-UUID
strings, numbers, booleans, and ``None`` pass through unchanged.
This means meaningful drift (different RPC ids, different non-UUID
arg values, different arg shapes) is still caught.
See the :func:`_freq_body_matcher` docstring for the full design.
"""
if isinstance(node, dict):
return {
k: _strip_volatile(v)
for k, v in node.items()
if not (isinstance(k, str) and k.lower() in _FREQ_VOLATILE_KEYS)
}
if isinstance(node, list):
return [_strip_volatile(v) for v in node]
if isinstance(node, str):
return _normalize_uuids(node)
return node
# Single token every leaf collapses to under :func:`_shape_only`. Module-level
# constant so the unit tests can import it directly to assert the structural-
# skeleton contract. Update the placeholder string in lockstep with the test
# suite if it ever needs to change (low likelihood — the value is internal).
_LEAF = "_FREQ_LEAF"
def _shape_only(node: Any) -> Any:
"""Return a structural skeleton of ``node`` — preserves nesting, drops values.
Used for the **batchexecute** matcher path where leaf-value drift is
common between recording sessions and the test suite:
- Cassettes were recorded with one set of fixture values (notebook UUIDs,
page sizes, free-form note titles, source URLs, ``null`` placeholders
for optional slots, ...) and the tests now run with different fixture
values that may fill or null those same slots differently.
- The recorded RESPONSE is the asset of interest — replaying it back
for the new request is the entire point of cassette replay. The
matcher only needs to confirm "same RPC kind, same nesting shape",
not "same leaf values". ``rpcids`` (URL-query matcher) already gates
RPC identity; this matcher is the body-level structural double-check.
Transformations:
1. Recursively walk lists and dicts; preserve list LENGTHS and dict
KEY SETS (after volatile-key stripping). This is what the matcher
actually compares.
2. Strip volatile dict keys (per :data:`_FREQ_VOLATILE_KEYS`).
3. Replace every non-container leaf — including ``None`` — with
:data:`_LEAF`. ``None`` is folded too because in practice the
biggest source of test/cassette drift is "this optional slot was
``null`` at record time and is filled at replay time (or
vice-versa)". The matcher's job stops at confirming list shape;
fill-vs-null is the test's own assertion territory.
The streaming-chat matcher path deliberately uses :func:`_strip_volatile`
(which preserves non-UUID string and number leaves AND ``None``) instead
because the chat-shape tests rely on slot-7 notebook-id equality and
slot-4 conv-id drift — see ``test_vcr_config.py`` for the exhaustive
slot-by-slot contract.
"""
if isinstance(node, dict):
return {
k: _shape_only(v)
for k, v in node.items()
if not (isinstance(k, str) and k.lower() in _FREQ_VOLATILE_KEYS)
}
if isinstance(node, list):
return [_shape_only(v) for v in node]
return _LEAF
_CREATE_ARTIFACT_RPC_ID = "R7cb6c"
_CREATE_ARTIFACT_OPTIONS_SENTINEL = {"_CREATE_ARTIFACT_CLIENT_OPTIONS": True}
_CREATE_ARTIFACT_OLD_CLIENT_OPTIONS = [2]
_CREATE_ARTIFACT_LIVE_CLIENT_OPTIONS = [
2,
None,
None,
[1, None, None, None, None, None, None, None, None, None, [1]],
[[1, 4, 8, 2, 3, 6]],
]
def _is_create_artifact_client_options(node: Any) -> bool:
"""Return whether ``node`` is a known CREATE_ARTIFACT options envelope.
Older cassettes recorded the compact ``[2]`` first param. Live UI captures
on 2026-06-15 send ``[2, None, None, ..., [[1, 4, 8, 2, 3, 6]]]`` instead.
Both identify the same client-options slot for matcher purposes; the actual
artifact spec remains matched structurally.
"""
if not isinstance(node, list):
return False
return node in (
_CREATE_ARTIFACT_OLD_CLIENT_OPTIONS,
_CREATE_ARTIFACT_LIVE_CLIENT_OPTIONS,
)
def _normalize_create_artifact_options(decoded_outer: Any) -> Any:
"""Canonicalize old/new CREATE_ARTIFACT client-options shapes for VCR replay."""
if not isinstance(decoded_outer, list):
return decoded_outer
normalized_outer: list[Any] = []
for batch in decoded_outer:
if not isinstance(batch, list):
normalized_outer.append(batch)
continue
normalized_batch: list[Any] = []
for entry in batch:
if (
isinstance(entry, list)
and len(entry) >= 2
and entry[0] == _CREATE_ARTIFACT_RPC_ID
and isinstance(entry[1], list)
and entry[1]
and _is_create_artifact_client_options(entry[1][0])
):
args = list(entry[1])
args[0] = _CREATE_ARTIFACT_OPTIONS_SENTINEL
normalized_batch.append([entry[0], args, *entry[2:]])
else:
normalized_batch.append(entry)
normalized_outer.append(normalized_batch)
return normalized_outer
def _normalize_freq_string(body: str) -> str | None:
"""Extract and lightly-normalize the raw ``f.req`` value from a form body.
Returns ``None`` when the body is not form-encoded or does not contain an
``f.req`` field. The returned string is the raw ``f.req`` value with
surrounding whitespace stripped — used as the fallback comparison key when
JSON parsing of the envelope fails (malformed payload, unexpected shape).
"""
qs = parse_qs(body)
f_req_values = qs.get("f.req", [])
if not f_req_values:
return None
f_req = f_req_values[0]
if not f_req:
return None
return f_req.strip()
def _freq_body_matcher(r1: Any, r2: Any) -> bool:
"""Match form-encoded RPC requests by their decoded ``f.req`` payload.
Now wired into the **default** ``match_on`` tuple so every cassette
replay benefits from body-level disambiguation, not just the streaming
endpoints. Two shapes are recognized:
1. **Streaming-chat envelope** ``[null, "<inner_json>"]`` where the inner
JSON is a positional parameter list. Used by the streaming chat
endpoint. Volatile slot 4 (``conversation_id``) is dropped — the
server assigns a fresh conversation_id on each ask and the client
echoes it back, so equality there would break every cassette replay.
Slot 7 (``notebook_id``) and the param-count are still checked.
2. **Batchexecute envelope** ``[[[rpc_id, args_json, null, "generic"]]]``
where ``args_json`` is itself a JSON-encoded list of positional
arguments. Used by the standard batchexecute POST. After decoding
the inner ``args_json`` we hand the result to :func:`_shape_only`,
which strips volatile dict keys (:data:`_FREQ_VOLATILE_KEYS`) and
folds every non-container leaf — strings, numbers, booleans,
``None`` — onto :data:`_LEAF` before comparison. The matcher then
fails on structural drift (different arg-list lengths, different
nesting depths, different non-volatile dict key sets) and passes
on leaf-value drift (different fixture UUIDs, different free-form
text, ``null`` vs filled in the same slot). RPC-id equality is
enforced upstream by the URL ``rpcids`` matcher, so collapsing
the RPC-id leaf here is intentional and documented in the
:func:`_shape_only` docstring. This shape-only comparison is the
spec's "widen the volatile-key scrub list" escape hatch from P1-3
— strict leaf matching would break cassette replay across
recording sessions; see the PR body for the full trade-off.
Robustness rules:
- If **both** bodies lack a parseable ``f.req`` field (e.g. GET requests,
uploads, the matcher invoked on a multipart-bodied request), return
``True`` so the other ``match_on`` matchers (``method`` / ``path`` /
``rpcids`` / ...) drive the decision. Returning ``False`` here would
incorrectly block every non-RPC request the cassette contains.
- If **exactly one** body carries ``f.req``, return ``False`` — the two
requests are structurally different.
- If JSON parsing of the envelope fails on either side (malformed payload
from a weird recorder, future shape we don't know about), fall back to
a normalized **string** comparison of the raw ``f.req`` value. This
keeps the matcher conservative: byte-identical bodies still match,
different bodies still mismatch, but we no longer crash or silently
accept the wrong cassette entry.
Returns:
``True`` if the two requests are considered the same interaction,
``False`` otherwise.
"""
def _decode_freq_envelope(request: Any) -> tuple[str | None, Any | None]:
"""Return ``(raw_f_req, parsed_payload)`` for a request.
- ``raw_f_req`` is the URL-decoded ``f.req`` value (string), or
``None`` if the body lacks ``f.req`` entirely.
- ``parsed_payload`` is one of:
* ``("chat", params_list)`` — streaming-chat shape recognized.
* ``("batch", outer_list)`` — batchexecute shape recognized.
* ``("raw", f_req)`` — JSON parse succeeded but shape is unfamiliar;
matcher falls back to comparing the raw normalized string.
* ``None`` — JSON parse failed entirely; same string fallback.
"""
body = request.body
if not body:
return None, None
if isinstance(body, bytes):
try:
body = body.decode("utf-8")
except UnicodeDecodeError:
return None, None
f_req = _normalize_freq_string(body)
if f_req is None:
return None, None
try:
outer = json.loads(f_req)
except (json.JSONDecodeError, ValueError, TypeError):
return f_req, None
# Streaming-chat envelope: [null, "<inner_json>"].
if (
isinstance(outer, list)
and len(outer) >= 2
and outer[0] is None
and isinstance(outer[1], str)
):
try:
params = json.loads(outer[1])
except (json.JSONDecodeError, ValueError, TypeError):
return f_req, None
if not isinstance(params, list):
return f_req, None
return f_req, ("chat", params)
# Batchexecute envelope: [[[rpc_id, args_json, ..., ...]]].
if isinstance(outer, list) and len(outer) >= 1 and isinstance(outer[0], list):
# Decode the inner ``args_json`` string slot (index 1 of each
# [rpc_id, args_json, ...] triple/quad) into structured form so
# volatile-key stripping can reach inside.
try:
decoded_outer: list[Any] = []
for batch in outer:
if not isinstance(batch, list):
decoded_outer.append(batch)
continue
decoded_batch: list[Any] = []
for entry in batch:
if (
isinstance(entry, list)
and len(entry) >= 2
and isinstance(entry[1], str)
):
try:
inner_args = json.loads(entry[1])
decoded_batch.append([entry[0], inner_args, *entry[2:]])
except (json.JSONDecodeError, ValueError, TypeError):
# Inner args wasn't JSON — keep the raw string
# so equality still distinguishes different
# arg payloads.
decoded_batch.append(entry)
else:
decoded_batch.append(entry)
decoded_outer.append(decoded_batch)
return f_req, ("batch", _normalize_create_artifact_options(decoded_outer))
except (TypeError, IndexError):
return f_req, None
# Unknown envelope shape — defer to raw string compare.
return f_req, ("raw", f_req)
raw1, payload1 = _decode_freq_envelope(r1)
raw2, payload2 = _decode_freq_envelope(r2)
# If neither side carries ``f.req`` at all, defer to other matchers.
if raw1 is None and raw2 is None:
return True
# Exactly one carries ``f.req`` — structurally different.
if raw1 is None or raw2 is None:
return False
# Both sides have ``f.req``. If either failed to parse, fall back to a
# normalized string compare on the raw value.
if payload1 is None or payload2 is None:
return raw1 == raw2
kind1, data1 = payload1
kind2, data2 = payload2
# Mismatched envelope shape — structurally different.
if kind1 != kind2:
return False
if kind1 == "chat":
# Streaming-chat: drop the volatile conversation_id at slot 4 and
# compare the rest of the param list with non-UUID leaves intact.
# The chat path keeps non-UUID strings (note titles, questions,
# notebook_id at slot 7) so the existing
# ``test_freq_matcher_notebook_id_mismatch_at_slot_seven`` contract
# holds — only UUIDs and empty strings normalize via
# ``_strip_volatile``/``_normalize_uuids``, and volatile dict keys
# are stripped from any nested dicts.
if len(data1) != len(data2):
return False
# Slot 4 (conversation_id) is the existing exemption — replace both
# sides with a shared sentinel so the comparison ignores it without
# relying on the leaf normalization (the recorded conv_id and the
# replay's are both UUIDs that would normalize identically anyway,
# but the sentinel makes the slot-4 exemption explicit in the code).
SENTINEL = object()
c1 = list(data1)
c2 = list(data2)
if len(c1) >= 5:
c1[4] = SENTINEL
c2[4] = SENTINEL
return _strip_volatile(c1) == _strip_volatile(c2)
if kind1 == "batch":
# Batchexecute: structural-skeleton comparison via :func:`_shape_only`.
# Catches the regression class P1-3 targets — different arg counts,
# different nesting depths, missing or extra positional slots, missing
# or extra non-volatile dict keys — while staying robust against leaf
# drift between recording sessions: different fixture UUIDs, different
# free-form text, ``null`` vs filled in the same slot, different page
# sizes all match because every leaf collapses to ``_LEAF``. RPC id
# equality is already enforced by the URL ``rpcids`` matcher, so
# collapsing the RPC id leaf here is intentional. See the
# :func:`_shape_only` docstring for the full trade-off rationale.
return _shape_only(data1) == _shape_only(data2)
# ``raw`` kind: fall back to raw string compare.
return raw1 == raw2
# =============================================================================
# VCR Configuration
# =============================================================================
# Determine record mode from environment
# Set NOTEBOOKLM_VCR_RECORD=1 (or =true, =yes) to record new cassettes
_record_mode = "new_episodes" if _is_vcr_record_mode() else "none"
# Main VCR instance for notebooklm-py tests
notebooklm_vcr = vcr.VCR(
# Cassette storage location
cassette_library_dir="tests/cassettes",
# Record mode: 'none' = only replay (CI), 'new_episodes' = record if missing
record_mode=_record_mode,
# Match requests by method and path, plus body-level disambiguators:
# - ``rpcids`` disambiguates batchexecute POSTs by their query-string
# ``rpcids`` parameter (all batchexecute POSTs share the URL path).
# - ``freq`` disambiguates by the decoded form-body ``f.req`` payload.
# It is a no-op when neither side has ``f.req`` (defers to the other
# matchers), so it is safe to enable globally. When it does parse, it
# catches artifact-ID / notebook-ID / source-ID drift between record
# and replay that ``rpcids`` alone cannot see (e.g. two ``gArtLc``
# POSTs against different artifact UUIDs share the same rpcids value
# but carry different ``f.req`` bodies).
match_on=["method", "scheme", "host", "port", "path", "rpcids", "freq"],
# Scrub sensitive data before recording
before_record_request=scrub_request,
before_record_response=scrub_response,
# Filter these headers entirely (don't record them at all)
filter_headers=[
"Authorization",
"X-Goog-AuthUser",
"X-Client-Data", # Chrome user data header
],
# Decode compressed responses for easier inspection
decode_compressed_response=True,
)
# Register custom matcher for rpcids-based request differentiation
notebooklm_vcr.register_matcher("rpcids", _rpcids_matcher)
# ``freq`` is wired into the default ``match_on`` tuple above. The matcher
# returns ``True`` (defer to other matchers) when neither request carries an
# ``f.req`` field, so endpoints that don't use ``f.req`` are unaffected; when
# both sides carry ``f.req``, the matcher decodes the form-body envelope and
# compares the normalized structure (with volatile keys like ``timestamp`` /
# ``requestId`` stripped).
notebooklm_vcr.register_matcher("freq", _freq_body_matcher)