Files
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

423 lines
18 KiB
Python

"""Multi-source streaming-chat VCR cassette.
This module records and replays a single ``chat.ask`` call against a
fresh scratch notebook that carries **five text sources**, with all five
source IDs passed explicitly to ``ask``. The point is to lock in the
on-wire shape of the **nested-list source-id encoder** for the chat
endpoint — the audit flagged this path as fragile because the
encoding has two distinct nesting depths (``[[id]]`` per source, then
collected into an outer list) and the per-method test coverage prior
to this cassette only exercised the zero-source and one-source cases via the
default-sources branch (``ask`` calls ``get_source_ids`` itself).
What this guards
----------------
``_chat._build_chat_request`` builds the params with::
sources_array = nest_source_ids(source_ids, 2)
params = [sources_array, question, conversation_history, ...]
``nest_source_ids(ids, 2)`` wraps each id with two inner lists, giving
``[[[id1]], [[id2]], [[id3]], [[id4]], [[id5]]]``. A regression that
silently flattens to depth 1 (``[[id1], [id2], ...]``) or depth 3
(``[[[[id1]]], ...]``) would still type-check and still POST, but the
server would return an error or — worse — silently drop sources. The
cassette captures the recorded ``f.req`` body so the unit-level
``test_chat_request_carries_five_source_ids_nested`` assertion has a
real on-wire reference to compare against.
Recording
---------
::
NOTEBOOKLM_VCR_RECORD=1 uv run pytest \\
tests/integration/test_chat_multi_source_vcr.py -v -s
The recording flow uses **its own scratch notebook**, NOT the shared
``NOTEBOOKLM_GENERATION_NOTEBOOK_ID``, so parallel cassette-recording
agents don't trip over one another. The scratch notebook and its
sources are deleted at the end of the recording run; only the chat
``ask`` interaction is captured inside the cassette context.
Replay
------
Replay opts into the ``freq`` body matcher on top of the
default ``method/scheme/host/port/path`` matchers. The streaming-chat
endpoint at ``/GenerateFreeFormStreamed`` does not carry ``rpcids``, so without
``freq`` two chat POSTs would be indistinguishable. ``freq`` decodes
``f.req`` and disambiguates by param count + ``notebook_id`` at slot 7,
which is exactly what we need: the cassette's recorded ``notebook_id``
(a now-deleted scratch UUID) is read out of the cassette and passed
back into ``ask`` so the replay matches the recording byte-for-byte at
the matcher's chosen slots.
"""
from __future__ import annotations
import json
import re
import sys
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs
import pytest
import yaml
from notebooklm import NotebookLMClient
from tests.integration.conftest import _vcr_record_mode, get_vcr_auth, skip_no_cassettes
from tests.vcr_config import notebooklm_vcr
pytestmark = [pytest.mark.vcr, skip_no_cassettes]
CASSETTE_NAME = "chat_ask_multi_source.yaml"
CASSETTE_PATH = Path(__file__).parent.parent / "cassettes" / CASSETTE_NAME
# Number of sources recorded into the cassette. Five is the smallest value
# that comfortably exceeds the "two or three sources" gut-check most chat
# call sites use during dev and still keeps the cassette small (text
# sources, one short paragraph each). The ``acceptance`` clause in the
# plan requires 5+; we record exactly 5.
SOURCE_COUNT = 5
# Question text. Kept short, generic, and PII-free — the question text
# is NOT scrubbed by ``cassette_patterns.py``, so we deliberately avoid
# anything that could resemble an account-linked phrase.
QUESTION = "Summarize all sources in one short paragraph."
# =============================================================================
# Cassette parsing helpers (used by both recording-time and replay tests)
# =============================================================================
def _find_chat_interaction(cassette: dict[str, Any]) -> dict[str, Any]:
"""Locate the single ``GenerateFreeFormStreamed`` interaction inside the cassette.
The cassette context wraps exactly one chat POST; the helper raises a
clear AssertionError if a future recording accidentally captures more
or fewer interactions.
"""
chat_interactions = [
interaction
for interaction in cassette.get("interactions", [])
if "GenerateFreeFormStreamed" in interaction.get("request", {}).get("uri", "")
]
assert len(chat_interactions) == 1, (
f"expected exactly one GenerateFreeFormStreamed interaction in {CASSETTE_NAME}, "
f"found {len(chat_interactions)}"
)
return chat_interactions[0]
def _decode_freq_params(body: str | bytes) -> list[Any]:
"""Decode the form-encoded ``f.req`` body into its param list.
Mirrors the logic in ``vcr_config._freq_body_matcher._extract_freq``;
duplicated here so the replay test does not depend on vcr_config's
private helper.
"""
if isinstance(body, bytes):
body = body.decode("utf-8")
qs = parse_qs(body)
f_req_values = qs.get("f.req", [])
assert f_req_values, f"f.req not found in body: {body[:200]!r}"
outer = json.loads(f_req_values[0])
assert isinstance(outer, list) and len(outer) >= 2, "f.req envelope malformed"
inner = outer[1]
assert isinstance(inner, str), "f.req inner JSON missing"
params = json.loads(inner)
assert isinstance(params, list), "f.req params not a list"
return params
def _load_cassette_params() -> tuple[list[Any], str]:
"""Return ``(decoded_params, notebook_id)`` from the recorded chat POST.
Reads the cassette from disk so the replay test stays decoupled from
any state the recording test left behind.
"""
assert CASSETTE_PATH.exists(), (
f"cassette missing: {CASSETTE_PATH}. "
"Re-record with NOTEBOOKLM_VCR_RECORD=1 — see module docstring."
)
with CASSETTE_PATH.open(encoding="utf-8") as fh:
cassette = yaml.safe_load(fh)
interaction = _find_chat_interaction(cassette)
body = interaction["request"]["body"]
params = _decode_freq_params(body)
# Bounds-guard ``params[7]`` so a malformed/short cassette fails with a
# message that identifies the helper rather than a bare ``IndexError``.
# The caller's ``len(params) == 9`` assertion would otherwise fire too
# late (after this helper has already crashed).
assert len(params) > 7, (
f"cassette params too short: expected at least 8 slots, got {len(params)}. "
f"Re-record with NOTEBOOKLM_VCR_RECORD=1."
)
notebook_id = params[7]
assert isinstance(notebook_id, str), f"slot 7 (notebook_id) not a string: {notebook_id!r}"
return params, notebook_id
def _extract_source_ids_from_nested(sources_array: list[Any]) -> list[str]:
"""Flatten the depth-2 nested ``[[[id]], ...]`` structure back to ids.
``nest_source_ids(ids, 2)`` wraps each id into ``[[id]]`` and collects
them into an outer list. This helper undoes that exact shape and
raises a clear AssertionError if any slot deviates — making the
replay test fail loudly on an encoder regression rather than
silently returning an empty list.
"""
extracted: list[str] = []
for index, slot in enumerate(sources_array):
assert isinstance(slot, list) and len(slot) == 1, (
f"sources_array slot {index} is not a single-element list: {slot!r}"
)
inner = slot[0]
assert isinstance(inner, list) and len(inner) == 1, (
f"sources_array slot {index} inner is not a single-element list: {inner!r}"
)
source_id = inner[0]
assert isinstance(source_id, str) and source_id, (
f"sources_array slot {index} id is not a non-empty string: {source_id!r}"
)
extracted.append(source_id)
return extracted
# =============================================================================
# Recording-time scratch notebook setup
# =============================================================================
#
# Setup and teardown live OUTSIDE the cassette context. The cassette
# context wraps only the ``ask`` call, so the cassette captures exactly
# one streaming-chat POST and nothing else — no notebook-create, no
# source-add, no notebook-delete clutter to mismatch on replay.
async def _seed_scratch_notebook(
client: NotebookLMClient,
) -> tuple[str, list[str]]:
"""Create a fresh notebook with ``SOURCE_COUNT`` text sources.
Returns ``(notebook_id, source_ids)``. Each source carries a short,
PII-free paragraph; the title embeds a UUID so a re-run never
collides with a leftover source from a prior aborted recording (the
UUID also rules out title-based dedupe heuristics).
"""
title = f"T8.E6 scratch multi-source chat ({uuid.uuid4()})"
notebook = await client.notebooks.create(title)
notebook_id = notebook.id
# Five short, generic paragraphs. Distinct enough that the answer
# has something to summarize, but boring enough that no scrubbing
# corner case fires on the recorded request bodies.
contents = [
(
"Bicycles are human-powered, pedal-driven vehicles with two wheels "
"attached to a frame. They are widely used for transport, recreation, "
"and exercise across many countries."
),
(
"Lighthouses are towers built near coastlines to emit light and warn "
"ships of dangerous shoals. Their construction varies with terrain "
"but the navigational role is consistent worldwide."
),
(
"Tea is a beverage prepared by pouring hot water over cured leaves "
"of the Camellia sinensis plant. Different processing methods yield "
"green, black, oolong, and white tea varieties."
),
(
"Sourdough bread is leavened by naturally occurring yeasts and "
"lactobacilli in a flour-and-water starter. The long fermentation "
"produces the characteristic tangy flavor and chewy crumb."
),
(
"Origami is the Japanese art of paper folding. Starting from a "
"single sheet, a sequence of folds produces shapes ranging from "
"simple cranes to complex modular geometric forms."
),
]
assert len(contents) == SOURCE_COUNT, "contents and SOURCE_COUNT must agree"
source_ids: list[str] = []
for index, content in enumerate(contents, start=1):
source = await client.sources.add_text(
notebook_id,
title=f"Source {index} ({uuid.uuid4()})",
content=content,
)
source_ids.append(source.id)
# Wait until all sources are ready so the chat call actually has
# something to retrieve. The text-source ingestion is normally fast
# but not instant; ``wait_for_sources`` polls server-side state.
await client.sources.wait_for_sources(notebook_id, source_ids, timeout=120.0)
return notebook_id, source_ids
async def _teardown_scratch_notebook(client: NotebookLMClient, notebook_id: str) -> None:
"""Delete the scratch notebook so the recording leaves no residue.
Failures here are logged but not raised — a leaked scratch notebook
is a maintainer hygiene issue, not a correctness failure for the
cassette we just recorded. The maintainer can prune leftovers
manually via ``notebooklm list``.
"""
try:
await client.notebooks.delete(notebook_id)
except Exception as exc: # noqa: BLE001 — best-effort cleanup
print(
f"WARNING: failed to delete scratch notebook {notebook_id}: {exc}",
file=sys.stderr,
)
# =============================================================================
# Replay test — runs from the recorded cassette under normal CI
# =============================================================================
class TestChatMultiSource:
"""Multi-source streaming-chat ask: recording + replay."""
@pytest.mark.vcr
@pytest.mark.asyncio
async def test_ask_with_five_explicit_sources(self) -> None:
"""A chat ask against five explicit sources round-trips successfully.
Recording-mode behavior: create scratch notebook + five sources
outside the cassette context, enter the cassette context for
exactly the ``ask`` call, then exit and delete the notebook.
Replay-mode behavior: read the recorded ``notebook_id`` and
``source_ids`` out of the cassette, pass them back into ``ask``
so the request body matches the cassette's recorded payload at
every slot the ``freq`` matcher inspects (param count + slot 7
notebook_id), then assert the recorded answer is non-empty.
"""
auth = await get_vcr_auth()
async with NotebookLMClient(auth) as client:
if _vcr_record_mode:
notebook_id, source_ids = await _seed_scratch_notebook(client)
try:
with notebooklm_vcr.use_cassette(
CASSETTE_NAME,
# Opt-in to the ``freq`` body matcher. The
# streaming-chat POST has no ``rpcids`` query
# param so the body-aware matcher is the only way
# to disambiguate it from any future chat
# cassette that hits the same path.
match_on=[
"method",
"scheme",
"host",
"port",
"path",
"freq",
],
):
result = await client.chat.ask(
notebook_id,
QUESTION,
source_ids=source_ids,
)
finally:
await _teardown_scratch_notebook(client, notebook_id)
else:
# Replay path: discover the recorded inputs from the
# cassette so the request the client builds matches the
# request the cassette holds. Single helper call —
# ``params`` carries the sources_array at slot 0 and
# ``recorded_notebook_id`` is what slot 7 already
# carries (validated by the helper).
params, recorded_notebook_id = _load_cassette_params()
recorded_source_ids = _extract_source_ids_from_nested(params[0])
with notebooklm_vcr.use_cassette(
CASSETTE_NAME,
match_on=[
"method",
"scheme",
"host",
"port",
"path",
"freq",
],
):
result = await client.chat.ask(
recorded_notebook_id,
QUESTION,
source_ids=recorded_source_ids,
)
# The streaming-chat endpoint always returns a non-empty answer
# for a well-formed multi-source ask. Empty would indicate a
# server-side error path leaked through ``AskResult``.
assert result is not None
assert result.answer is not None
assert isinstance(result.answer, str)
assert result.answer.strip(), "chat answer should be non-empty"
assert result.conversation_id is not None
def test_cassette_carries_all_five_source_ids_with_correct_nesting(
self,
) -> None:
"""The recorded ``f.req`` body has all five source-ids at depth 2.
This is the regression guard for the fragile nested-list encoder
(audit C-class finding on ``_build_chat_request``). Failure
modes the test catches:
1. ``sources_array`` collapsed to depth 1 — slot[0] would NOT be
a single-element list-of-list; the
``_extract_source_ids_from_nested`` parser raises.
2. Source list truncated — the recorded length deviates from
``SOURCE_COUNT``.
3. Source IDs lost their string shape — the parser raises
because a slot's terminal value is not a non-empty ``str``.
The cassette is the source of truth; we parse it directly
rather than relying on the live client's behavior so the
assertion is independent of the implementation under test.
"""
params, _notebook_id = _load_cassette_params()
# Param count must match the 9-slot shape ``_build_chat_request``
# documents. A drift here is itself a regression worth shouting
# about (stale-cassette regression class).
assert len(params) == 9, (
f"chat f.req param count drift: expected 9, got {len(params)}. params={params!r}"
)
sources_array = params[0]
assert isinstance(sources_array, list), (
f"slot 0 (sources_array) must be a list, got {type(sources_array).__name__}"
)
assert len(sources_array) == SOURCE_COUNT, (
f"recorded sources_array has {len(sources_array)} entries, expected {SOURCE_COUNT}"
)
# Round-trip through the strict parser — raises with a clear
# message if any slot deviates from ``[[id]]``.
extracted_ids = _extract_source_ids_from_nested(sources_array)
assert len(extracted_ids) == SOURCE_COUNT
# Source-ids should look like UUIDs (Google-issued source IDs
# are UUIDs). The exact value of each id is not asserted —
# rerecording yields fresh ids and the test must stay green.
uuid_pat = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
for sid in extracted_ids:
assert uuid_pat.match(sid), f"recorded source id is not a UUID: {sid!r}"
# Slot positions that ``_build_chat_request`` documents — assert
# them so any reshuffle of the 9-param shape fails this test
# immediately rather than silently changing wire format.
assert params[1] == QUESTION, (
f"slot 1 (question) drift: expected {QUESTION!r}, got {params[1]!r}"
)
assert params[3] == [2, None, [1], [1]], f"slot 3 (mode tuple) drift: got {params[3]!r}"
assert params[8] == 1, f"slot 8 (always-1) drift: got {params[8]!r}"