Files
teng-lin--notebooklm-py/tests/unit/test_lifecycle_executor_reuse.py
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

142 lines
5.4 KiB
Python

"""Stage B1 PR 2 — executor persists across ``close()`` → ``open()``.
Replacement regression test for the deleted
``test_runtime_lifecycle.test_close_nulls_rpc_executor``. Before Stage B1 PR 2
of the post-refactoring plan, lifecycle close logic nulled out the client-owned
executor so a follow-up :meth:`open` would rebuild it against the new
``httpx.AsyncClient``.
PR 2 deleted both that null line and the lazy factory itself — the
executor is bound exactly once by the composition root
(:func:`notebooklm._runtime.init.compose_client_internals`) via
:class:`notebooklm._client_composed.ClientComposed`, and the same instance
survives any ``close()`` → ``open()`` cycle. This is safe because the executor's
transport collaborator (:class:`Kernel`) rebuilds its
``httpx.AsyncClient`` lazily on each :meth:`Kernel.open`, so a stale
executor reference continues to drive RPCs against a fresh transport.
This module pins three load-bearing invariants:
1. The :class:`RpcExecutor` instance is identity-equal before and after
a full ``close()`` → ``open()`` cycle.
2. The reused executor can still execute an RPC after the cycle (it is
not bound to a stale transport reference).
3. ``NotebookLMClient._rpc_executor`` is the same client-owned executor stored
on ``ClientComposed``.
"""
from __future__ import annotations
from typing import Any
import pytest
from notebooklm.auth import AuthTokens
from notebooklm.rpc import RPCMethod
from tests._helpers.client_factory import build_client_shell_for_tests
def _make_auth() -> AuthTokens:
return AuthTokens(
cookies={"SID": "x", "__Secure-1PSIDTS": "y"},
csrf_token="csrf",
session_id="sid",
)
@pytest.mark.asyncio
async def test_executor_identity_survives_close_then_open() -> None:
"""``open()`` → ``close()`` → ``open()`` preserves the executor instance.
Pins the Stage B1 PR 2 contract: the composition root binds the
executor exactly once and :meth:`ClientLifecycle.close` no longer
nulls ``host._rpc_executor``. The same :class:`RpcExecutor`
reference drives RPCs across the lifecycle cycle — feature
adapters that captured the executor at construction time
(``ChatAPI`` / ``SourcesAPI`` / etc.) do not need to re-grab it.
"""
core = build_client_shell_for_tests(_make_auth())
initial_executor = core._rpc_executor
assert initial_executor is not None, "composition root must bind the executor"
await core.__aenter__()
try:
assert core._rpc_executor is initial_executor, (
"open() must not rebind the executor — it persists from composition"
)
finally:
await core.close()
# Stage B1 PR 2 dropped the close-time null on _rpc_executor; the
# binding survives close().
assert core._rpc_executor is initial_executor, (
"close() must not null the executor — Stage B1 PR 2 dropped that step"
)
await core.__aenter__()
try:
assert core._rpc_executor is initial_executor, (
"second open() also leaves the executor alone — same instance "
"throughout the close()→open() cycle"
)
finally:
await core.close()
@pytest.mark.asyncio
async def test_rpc_call_succeeds_after_close_then_open_with_same_executor() -> None:
"""A reused executor still executes RPCs after a full lifecycle cycle.
Production callers reach the executor as ``client._rpc_executor``;
if Stage B1 PR 2 had accidentally re-nulled the slot inside
:meth:`ClientLifecycle.close`, the second dispatch after the cycle
would raise ``AttributeError``. This test exercises the call path end-to-end
through a stubbed executor to confirm the binding survives.
"""
core = build_client_shell_for_tests(_make_auth())
executor = core._rpc_executor
assert executor is not None
# Stub ``rpc_call`` on the executor with a plain async function
# rather than ``unittest.mock.AsyncMock`` — ADR-0007 forbids
# ``Mock`` / ``AsyncMock`` attribute assignment as a test seam, so
# we use a captured-state ``async def`` to record the dispatch.
# This is the same pattern as ``_fixtures/fake_core.py``: an
# ordinary callable substituted for a method, no mock library
# involved.
sentinel: dict[str, Any] = {"call_count": 0}
async def fake_rpc_call(*_args: Any, **_kwargs: Any) -> str:
sentinel["call_count"] += 1
return "ok"
executor.rpc_call = fake_rpc_call # type: ignore[method-assign,assignment]
# Drive a full lifecycle cycle.
await core.__aenter__()
result1 = await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
await core.close()
# Critical re-open + rpc_call — the deleted close-time null would
# have left ``_rpc_executor`` at ``None`` here, raising from the
# fail-fast guard.
await core.__aenter__()
try:
result2 = await core._rpc_executor.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
finally:
await core.close()
assert result1 == "ok"
assert result2 == "ok"
assert sentinel["call_count"] == 2
# The executor reference never moved — both calls dispatched
# through the same fake.
assert core._rpc_executor is executor
def test_session_rpc_executor_forwards_to_client_composed() -> None:
"""``NotebookLMClient._rpc_executor`` reads through ``ClientComposed``."""
core = build_client_shell_for_tests(_make_auth())
assert core._rpc_executor is core._composed.executor