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

312 lines
12 KiB
Python

"""Unit tests for :class:`notebooklm._client_metrics.ClientMetrics`.
Covers the metrics helper in isolation; NotebookLMClient observability plumbing
(``metrics_snapshot`` and RPC telemetry emission) is exercised end-to-end in
``tests/unit/test_observability.py``. This file pins the helper-class invariants
that client/runtime observability depends on:
* ``__init__`` is event-loop-agnostic (no ``asyncio.*`` primitives).
* ``snapshot()`` returns a defensive copy, not a live reference.
* ``increment()`` and ``record_*`` mutations are visible in subsequent
snapshots and accumulate correctly.
* ``emit_rpc_event`` dispatches sync, async, and exception-raising callbacks
via ``maybe_await_callback`` (back-pressure semantics — never
fire-and-forget).
"""
from __future__ import annotations
import asyncio
import logging
import threading
import pytest
from notebooklm._client_metrics import ClientMetrics
from notebooklm.types import ClientMetricsSnapshot, RpcTelemetryEvent
# ---------------------------------------------------------------------------
# Construction
# ---------------------------------------------------------------------------
def test_init_uses_no_asyncio_primitives() -> None:
"""``ClientMetrics`` must be constructible outside a running event loop.
Regression guard: ``Session`` is routinely instantiated synchronously
(e.g. ``NotebookLMClient(auth)`` before ``asyncio.run``). If
``ClientMetrics.__init__`` ever introduces an ``asyncio.Lock``/``Event``
/``Condition``, that synchronous construction path will break on Python
versions where those primitives require a running loop.
"""
# Construct outside any loop — must not raise.
metrics = ClientMetrics()
assert isinstance(metrics._metrics_lock, type(threading.Lock()))
assert isinstance(metrics._metrics, ClientMetricsSnapshot)
assert metrics._on_rpc_event is None
def test_init_accepts_callback() -> None:
def cb(_event: RpcTelemetryEvent) -> None:
pass
metrics = ClientMetrics(on_rpc_event=cb)
assert metrics._on_rpc_event is cb
# ---------------------------------------------------------------------------
# snapshot()
# ---------------------------------------------------------------------------
def test_snapshot_returns_defensive_copy() -> None:
"""Snapshot must not hand out a reference to the live in-place dataclass.
Counters are frozen today, but external code shouldn't rely on that to
avoid mutating the live state.
"""
metrics = ClientMetrics()
first = metrics.snapshot()
metrics.increment(rpc_calls_started=1)
second = metrics.snapshot()
# First snapshot reflects pre-increment state — proves we didn't hand
# out a live reference that then "saw" the increment.
assert first.rpc_calls_started == 0
assert second.rpc_calls_started == 1
assert first is not second
assert first is not metrics._metrics
# ---------------------------------------------------------------------------
# increment()
# ---------------------------------------------------------------------------
def test_increment_accumulates_across_calls() -> None:
metrics = ClientMetrics()
metrics.increment(rpc_calls_started=1)
metrics.increment(rpc_calls_started=2, rpc_calls_succeeded=3)
snapshot = metrics.snapshot()
assert snapshot.rpc_calls_started == 3
assert snapshot.rpc_calls_succeeded == 3
assert snapshot.rpc_calls_failed == 0
def test_decode_errors_counter_defaults_zero_and_accumulates() -> None:
"""The drift counter (issue #1492) starts at 0 and accumulates through the
same locked ``increment()`` path as every other counter — confirming it is
threadsafe-consistent with the existing metrics locking.
"""
metrics = ClientMetrics()
assert metrics.snapshot().rpc_decode_errors == 0
metrics.increment(rpc_decode_errors=1)
metrics.increment(rpc_decode_errors=2)
assert metrics.snapshot().rpc_decode_errors == 3
def test_increment_holds_metrics_lock_during_update() -> None:
"""``increment()`` itself must run under ``_metrics_lock``.
Spawn a worker that calls ``metrics.increment(...)`` while the main
thread already holds ``_metrics_lock``. The worker must block inside
``increment`` until the main thread releases — verified by the
``timeout=0.05`` ``Event.wait()`` returning False. Once the main thread
drops the lock, the worker completes and the increment is visible in
the next snapshot.
"""
metrics = ClientMetrics()
increment_finished = threading.Event()
def run_increment() -> None:
metrics.increment(rpc_calls_started=1)
increment_finished.set()
# Hold the same lock that ``increment()`` needs; the worker must block
# inside its ``with self._metrics_lock`` until we release here.
with metrics._metrics_lock:
worker = threading.Thread(target=run_increment)
worker.start()
# Worker is blocked inside increment(); the post-increment event
# must not have fired yet.
assert not increment_finished.wait(timeout=0.05)
worker.join(timeout=1.0)
assert worker.is_alive() is False
assert increment_finished.is_set()
# And the increment actually landed — the lock release didn't drop it.
assert metrics.snapshot().rpc_calls_started == 1
# ---------------------------------------------------------------------------
# record_rpc_queue_wait / record_upload_queue_wait / record_lock_wait
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("method_name", "total_field", "max_field"),
[
("record_rpc_queue_wait", "rpc_queue_wait_seconds_total", "rpc_queue_wait_seconds_max"),
(
"record_upload_queue_wait",
"upload_queue_wait_seconds_total",
"upload_queue_wait_seconds_max",
),
("record_lock_wait", "lock_wait_seconds_total", "lock_wait_seconds_max"),
],
)
def test_record_wait_updates_total_and_max(
method_name: str, total_field: str, max_field: str
) -> None:
"""Each ``record_*`` family accumulates total seconds and tracks max."""
metrics = ClientMetrics()
record = getattr(metrics, method_name)
record(0.10)
record(0.25) # new max
record(0.05) # smaller — total grows, max stays
snapshot = metrics.snapshot()
assert getattr(snapshot, total_field) == pytest.approx(0.40)
assert getattr(snapshot, max_field) == pytest.approx(0.25)
# ---------------------------------------------------------------------------
# emit_rpc_event — sync callback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_emit_rpc_event_invokes_sync_callback() -> None:
events: list[RpcTelemetryEvent] = []
metrics = ClientMetrics(on_rpc_event=events.append)
event = RpcTelemetryEvent(method="GET_NOTEBOOK", status="success", elapsed_seconds=0.1)
await metrics.emit_rpc_event(event)
assert events == [event]
@pytest.mark.asyncio
async def test_emit_rpc_event_noop_when_callback_none() -> None:
"""No callback configured ⇒ silent no-op (and no AttributeError)."""
metrics = ClientMetrics(on_rpc_event=None)
event = RpcTelemetryEvent(method="GET_NOTEBOOK", status="success", elapsed_seconds=0.0)
# Must not raise.
await metrics.emit_rpc_event(event)
# ---------------------------------------------------------------------------
# emit_rpc_event — async callback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_emit_rpc_event_awaits_async_callback() -> None:
"""``emit_rpc_event`` must ``await`` an async callback before returning.
This is the back-pressure contract: a slow async callback intentionally
throttles the RPC path. If we ever switched to ``asyncio.create_task``
(fire-and-forget), this test would fail because ``events`` would still be
empty at the assertion point.
"""
events: list[RpcTelemetryEvent] = []
started_event = asyncio.Event()
finish_event = asyncio.Event()
async def slow_callback(event: RpcTelemetryEvent) -> None:
started_event.set()
await finish_event.wait()
events.append(event)
metrics = ClientMetrics(on_rpc_event=slow_callback)
event = RpcTelemetryEvent(method="ASK", status="success", elapsed_seconds=0.0)
emit_task = asyncio.create_task(metrics.emit_rpc_event(event))
await started_event.wait()
# If emit_rpc_event used create_task, it would already be done — but we
# required back-pressure, so the await is still pending.
assert not emit_task.done()
assert events == []
finish_event.set()
await emit_task
assert events == [event]
@pytest.mark.asyncio
async def test_emit_rpc_event_uses_maybe_await_callback_dispatch() -> None:
"""``emit_rpc_event`` dispatches via ``maybe_await_callback``.
Regression guard: a previous refactor candidate replaced the helper with
a custom ``asyncio.iscoroutine(result)`` branch. ``inspect.isawaitable``
(used by ``maybe_await_callback``) accepts a broader set of awaitables —
plain coroutines, generator-based coroutines, and custom ``__await__``
objects. Switching to ``iscoroutine`` would silently drop those.
"""
class CustomAwaitable:
"""Synchronous-looking callable that returns an arbitrary awaitable."""
def __init__(self) -> None:
self.called = False
def __call__(self, _event: RpcTelemetryEvent) -> object:
return self._coro()
async def _coro(self) -> None:
self.called = True
callback = CustomAwaitable()
metrics = ClientMetrics(on_rpc_event=callback)
await metrics.emit_rpc_event(
RpcTelemetryEvent(method="ASK", status="success", elapsed_seconds=0.0)
)
assert callback.called is True
# ---------------------------------------------------------------------------
# emit_rpc_event — exception-swallowing
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_emit_rpc_event_swallows_callback_exception(caplog) -> None:
"""A misbehaving user callback must not surface as an RPC failure.
Logged at WARNING under the ``notebooklm._core`` logger so existing
user-facing log filters on that namespace keep catching the diagnostic.
"""
def boom(_event: RpcTelemetryEvent) -> None:
raise ValueError("test-only callback failure")
metrics = ClientMetrics(on_rpc_event=boom)
with caplog.at_level(logging.WARNING, logger="notebooklm._core"):
# Must not raise.
await metrics.emit_rpc_event(
RpcTelemetryEvent(method="ASK", status="error", elapsed_seconds=0.0)
)
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert warnings, "expected a WARNING for the failing callback"
assert any("test-only callback failure" in r.getMessage() for r in warnings)
@pytest.mark.asyncio
async def test_emit_rpc_event_swallows_async_callback_exception(caplog) -> None:
"""Same swallow contract for async callbacks that raise."""
async def async_boom(_event: RpcTelemetryEvent) -> None:
raise RuntimeError("async test-only callback failure")
metrics = ClientMetrics(on_rpc_event=async_boom)
with caplog.at_level(logging.WARNING, logger="notebooklm._core"):
await metrics.emit_rpc_event(
RpcTelemetryEvent(method="ASK", status="error", elapsed_seconds=0.0)
)
assert any(
"async test-only callback failure" in r.getMessage()
for r in caplog.records
if r.levelno == logging.WARNING
)