项目文件夹

文件
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

702 行
26 KiB
Python

"""Regression tests for ``ChatAPI.ask`` after the chat-transport refactor.
These assertions pin down the new contract:
- ``ask`` uses ``self._reqid.next_reqid()`` for the URL ``_reqid`` param (the
``_reqid_counter`` property + deprecation gesture were retired in the
session-shrink arc; this test now guards against any new
``DeprecationWarning`` escaping ``_chat/api.py``).
- ``authuser=`` is present on the chat URL when ``account_email`` is set on
the auth tokens, mirroring the batchexecute path in
``RpcExecutor.build_url``. Previously omitted entirely on the chat endpoint.
- Concurrent ``asyncio.gather(ask*3)`` produces three distinct reqid values.
- 401 mid-chat triggers a refresh, and the post-refresh attempt's body
carries the refreshed CSRF token (snapshot-per-attempt invariant).
- ``NOTEBOOKLM_BL`` env override still works after the move to
:mod:`notebooklm._env`.
"""
from __future__ import annotations
import asyncio
import json
import re
import warnings
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, unquote, urlparse
import httpx
import pytest
from notebooklm import NotebookLMClient
from notebooklm._chat import ChatAPI
from notebooklm._request_types import AuthSnapshot
from notebooklm._runtime.config import DEFAULT_CHAT_RESPONSE_MAX_BYTES
from notebooklm.auth import AuthTokens
from notebooklm.exceptions import ChatError
from tests._helpers.client_factory import build_client_shell_for_tests
from tests.unit.conftest import install_post_as_stream
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_answer_response_body(
answer: str = "Refactor answer is long enough.",
*,
server_conv_id: str = "server-refactor-conv",
) -> bytes:
"""Build a minimal valid streaming chat response.
A ``server_conv_id`` is always present at ``first[2][0]`` because
``ChatAPI.ask`` requires the server to assign the id for new
conversations (issue #659); responses lacking one raise ``ChatError``.
"""
inner_json = json.dumps([[answer, None, [server_conv_id, 12345], None, [1]]])
chunk_json = json.dumps([["wrb.fr", None, inner_json]])
return f")]}}'\n{len(chunk_json)}\n{chunk_json}\n".encode()
def _extract_query_param(url: str, key: str) -> str | None:
qs = parse_qs(urlparse(url).query, keep_blank_values=True)
values = qs.get(key)
return values[0] if values else None
# ---------------------------------------------------------------------------
# chat timeout routing
# ---------------------------------------------------------------------------
class TestChatTimeoutRouting:
def test_client_uses_chat_specific_timeout_by_default(self):
auth = AuthTokens(cookies={"SID": "x"}, csrf_token="csrf", session_id="sid")
client = NotebookLMClient(auth, timeout=75.0)
assert client.chat._chat_timeout == 180.0
assert client.chat._chat_response_max_bytes == DEFAULT_CHAT_RESPONSE_MAX_BYTES
def test_client_chat_timeout_none_inherits_transport_timeout(self):
auth = AuthTokens(cookies={"SID": "x"}, csrf_token="csrf", session_id="sid")
client = NotebookLMClient(auth, timeout=75.0, chat_timeout=None)
assert client.chat._chat_timeout is None
def test_client_chat_response_max_bytes_none_inherits_shared_rpc_cap(self):
auth = AuthTokens(cookies={"SID": "x"}, csrf_token="csrf", session_id="sid")
client = NotebookLMClient(auth, timeout=75.0, chat_response_max_bytes=None)
assert client.chat._chat_response_max_bytes is None
def test_client_chat_timeout_override_wins(self):
auth = AuthTokens(cookies={"SID": "x"}, csrf_token="csrf", session_id="sid")
client = NotebookLMClient(
auth,
timeout=75.0,
chat_timeout=180.0,
chat_response_max_bytes=123456,
)
assert client.chat._chat_timeout == 180.0
assert client.chat._chat_response_max_bytes == 123456
@pytest.mark.parametrize("value", [0, -1])
def test_client_rejects_invalid_chat_response_max_bytes(self, value: int):
auth = AuthTokens(cookies={"SID": "x"}, csrf_token="csrf", session_id="sid")
with pytest.raises(ValueError, match="chat_response_max_bytes must be >= 1"):
NotebookLMClient(auth, chat_response_max_bytes=value)
@pytest.mark.asyncio
async def test_ask_passes_chat_read_timeout_response_cap_and_disables_timeout_retry(self):
"""``ask`` uses the chat-specific read window without retrying timed-out streams."""
transport = SimpleNamespace(
perform_authed_post=AsyncMock(
return_value=httpx.Response(
200,
request=httpx.Request("POST", "https://example.test/chat"),
content=_make_answer_response_body(),
)
)
)
chat = ChatAPI(
rpc=SimpleNamespace(),
transport=transport,
reqid=SimpleNamespace(next_reqid=AsyncMock(return_value=100000)),
loop_guard=SimpleNamespace(assert_bound_loop=lambda: None),
chat_timeout=45.0,
chat_response_max_bytes=987654,
)
result = await chat.ask(
"nb-1",
"Q?",
source_ids=["s1"],
conversation_id="conv-1",
)
assert result.answer == "Refactor answer is long enough."
assert transport.perform_authed_post.await_args.kwargs.get("read_timeout") == 45.0
assert transport.perform_authed_post.await_args.kwargs.get("max_response_bytes") == 987654
assert (
transport.perform_authed_post.await_args.kwargs.get("disable_read_timeout_retries")
is True
)
# ---------------------------------------------------------------------------
# authuser= URL parameter
# ---------------------------------------------------------------------------
class TestChatAuthuserParam:
"""``authuser=`` was previously omitted entirely on the chat endpoint."""
@pytest.mark.asyncio
async def test_authuser_set_when_account_email_provided(
self, httpx_mock, mock_get_conversation_id
):
"""When ``account_email`` is set on auth, chat URL carries authuser=email."""
auth = AuthTokens(
cookies={"SID": "x"},
csrf_token="csrf",
session_id="sid",
authuser=2,
account_email="user@example.com",
)
httpx_mock.add_response(
url=re.compile(r".*GenerateFreeFormStreamed.*"),
content=_make_answer_response_body(),
method="POST",
)
mock_get_conversation_id() # issue #659 post-ask round-trip
async with NotebookLMClient(auth) as client:
await client.chat.ask("nb_x", "Q?", source_ids=["s1"])
# Filter for the chat-ask request — the post-ask hPTbtc request also
# lands on the same authuser query, but we want to assert the chat
# leg specifically.
request = next(
r for r in httpx_mock.get_requests() if "GenerateFreeFormStreamed" in str(r.url)
)
# Email is preferred over the integer index because it survives
# browser-account reordering.
assert _extract_query_param(str(request.url), "authuser") == "user@example.com"
@pytest.mark.asyncio
async def test_authuser_set_when_only_authuser_index(
self, httpx_mock, mock_get_conversation_id
):
"""When only ``authuser`` is non-zero (no email), still emit the index."""
auth = AuthTokens(
cookies={"SID": "x"},
csrf_token="csrf",
session_id="sid",
authuser=3,
)
httpx_mock.add_response(
url=re.compile(r".*GenerateFreeFormStreamed.*"),
content=_make_answer_response_body(),
method="POST",
)
mock_get_conversation_id()
async with NotebookLMClient(auth) as client:
await client.chat.ask("nb_x", "Q?", source_ids=["s1"])
request = next(
r for r in httpx_mock.get_requests() if "GenerateFreeFormStreamed" in str(r.url)
)
assert _extract_query_param(str(request.url), "authuser") == "3"
@pytest.mark.asyncio
async def test_authuser_absent_for_default_profile(self, httpx_mock, mock_get_conversation_id):
"""No ``authuser=`` on the URL when authuser=0 and no email — matches the
previous-contract default-profile behavior (don't churn the existing single-account
contract)."""
auth = AuthTokens(
cookies={"SID": "x"},
csrf_token="csrf",
session_id="sid",
# authuser defaults to 0, account_email defaults to None
)
httpx_mock.add_response(
url=re.compile(r".*GenerateFreeFormStreamed.*"),
content=_make_answer_response_body(),
method="POST",
)
mock_get_conversation_id()
async with NotebookLMClient(auth) as client:
await client.chat.ask("nb_x", "Q?", source_ids=["s1"])
request = next(
r for r in httpx_mock.get_requests() if "GenerateFreeFormStreamed" in str(r.url)
)
assert _extract_query_param(str(request.url), "authuser") is None
# ---------------------------------------------------------------------------
# next_reqid + DeprecationWarning silence
# ---------------------------------------------------------------------------
class TestChatReqid:
"""``ChatAPI.ask`` must call ``self._reqid.next_reqid()`` — not poke
``_reqid_counter`` directly, which would emit ``DeprecationWarning``."""
@pytest.mark.asyncio
async def test_ask_uses_next_reqid_no_deprecation_warning(
self, httpx_mock, mock_get_conversation_id
):
"""No ``DeprecationWarning`` is emitted by ``_chat/api.py`` during ask()."""
auth = AuthTokens(
cookies={"SID": "x"},
csrf_token="csrf",
session_id="sid",
)
httpx_mock.add_response(
url=re.compile(r".*GenerateFreeFormStreamed.*"),
content=_make_answer_response_body(),
method="POST",
)
mock_get_conversation_id()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
async with NotebookLMClient(auth) as client:
await client.chat.ask("nb_x", "Q?", source_ids=["s1"])
chat_dep_warnings = [
w
for w in caught
if issubclass(w.category, DeprecationWarning)
and "_reqid_counter" in str(w.message)
and "_chat" in str(w.filename)
and "api.py" in str(w.filename)
]
assert chat_dep_warnings == [], (
f"_chat/api.py must not emit _reqid_counter DeprecationWarning; "
f"got: {[(str(w.filename), str(w.message)) for w in chat_dep_warnings]}"
)
@pytest.mark.asyncio
async def test_concurrent_asks_produce_distinct_reqids(
self, httpx_mock, mock_get_conversation_id
):
"""``asyncio.gather(ask*3)`` → three distinct ``_reqid`` URL values.
Previously, the body did ``self._core._collaborators.reqid_counter += 100000`` under
a read-modify-write race (``self._core`` was the pre-Phase-2 attribute
name, now ``self._runtime``); under concurrent gather() this collapsed
to a single reqid value. ``runtime.next_reqid()`` serializes the
increment under an asyncio.Lock, restoring monotonic distinct ids.
"""
auth = AuthTokens(
cookies={"SID": "x"},
csrf_token="csrf",
session_id="sid",
)
# One response per gathered chat-ask. pytest_httpx replays in order.
for _ in range(3):
httpx_mock.add_response(
url=re.compile(r".*GenerateFreeFormStreamed.*"),
content=_make_answer_response_body(),
method="POST",
)
# Plus the three post-ask hPTbtc round-trips (issue #659).
mock_get_conversation_id(reusable=True)
async with NotebookLMClient(auth) as client:
await asyncio.gather(
client.chat.ask("nb_x", "Q1", source_ids=["s1"]),
client.chat.ask("nb_x", "Q2", source_ids=["s1"]),
client.chat.ask("nb_x", "Q3", source_ids=["s1"]),
)
reqids = [
_extract_query_param(str(req.url), "_reqid")
for req in httpx_mock.get_requests()
if "GenerateFreeFormStreamed" in str(req.url)
]
assert len(reqids) == 3
assert all(r is not None for r in reqids)
assert len(set(reqids)) == 3, f"reqids must be distinct, got {reqids}"
# ---------------------------------------------------------------------------
# 401 mid-chat → snapshot regenerated for retry
# ---------------------------------------------------------------------------
class TestChatRefreshRetry:
"""Snapshot-per-attempt invariant: ``build_request`` is invoked once
per attempt with a *fresh* ``AuthSnapshot``, so the retry body carries
the post-refresh CSRF token rather than replaying the stale pre-refresh
body."""
@pytest.mark.asyncio
async def test_post_refresh_retry_uses_fresh_csrf_in_body(self, monkeypatch):
"""401 → refresh callback rotates CSRF → retry body contains new token."""
auth = AuthTokens(
cookies={"SID": "x"},
csrf_token="OLD_CSRF",
session_id="OLD_SID",
)
async def refresh() -> AuthTokens:
# Mutate the live auth tokens — the next snapshot picks this up.
auth.csrf_token = "NEW_CSRF"
auth.session_id = "NEW_SID"
return auth
core = build_client_shell_for_tests(
auth=auth, refresh_callback=refresh, refresh_retry_delay=0.0
)
await core.__aenter__()
try:
observed_bodies: list[str] = []
call_count = {"n": 0}
async def fake_post(url, *args, **kwargs): # type: ignore[no-untyped-def]
# The post-ask ``hPTbtc`` request from issue #659 also goes
# through this fake_post. Identify it by URL and return a
# minimal RPC response that decodes to a valid conv_id.
if "batchexecute" in str(url):
rpc_body = (
")]}'\n"
'63\n[["wrb.fr","hPTbtc","[[[\\"real-conv-id-from-hptbtc\\"]]]",null,null]]'
)
return httpx.Response(
200,
request=httpx.Request("POST", url),
content=rpc_body.encode(),
)
# Chat-ask path: capture the body and exercise the retry contract.
body = kwargs.get("content")
if isinstance(body, bytes):
body = body.decode()
observed_bodies.append(body or "")
call_count["n"] += 1
if call_count["n"] == 1:
# First attempt: 401 → triggers refresh path.
response = httpx.Response(401, request=httpx.Request("POST", url), content=b"")
raise httpx.HTTPStatusError("401", request=response.request, response=response)
# Second attempt (after refresh): return a valid answer.
return httpx.Response(
200,
request=httpx.Request("POST", url),
content=_make_answer_response_body(),
)
assert core._collaborators.kernel.http_client is not None
install_post_as_stream(
monkeypatch, core._collaborators.kernel.get_http_client(), fake_post
)
# Wave 8 of session-decoupling (ADR-0014 Rule 2 Corollary):
# ``ChatAPI`` takes its four direct collaborators by keyword
# arg. Wired here from the real ``Session`` under test so the
# refresh path exercises the production transport/rpc/reqid
# collaborators end-to-end.
# Stage B1 PR 2 deleted the Stage A accessors
# (``Session.session_transport`` / ``Session.collaborators``);
# read the private slots directly instead.
api = ChatAPI(
rpc=core._rpc_executor,
transport=core._composed.transport,
reqid=core._collaborators.reqid,
loop_guard=core._collaborators.lifecycle,
)
result = await api.ask("nb_x", "Q?", source_ids=["s1"])
assert call_count["n"] == 2
assert "Refactor answer is long enough." in result.answer
# First attempt body carries OLD_CSRF (pre-refresh snapshot).
assert "at=OLD_CSRF" in observed_bodies[0]
assert "at=NEW_CSRF" not in observed_bodies[0]
# Second attempt body carries NEW_CSRF (post-refresh snapshot)
# — this is the snapshot-per-attempt contract surfacing
# through chat_aware_authed_post.
assert "at=NEW_CSRF" in observed_bodies[1]
assert "at=OLD_CSRF" not in observed_bodies[1]
finally:
await core.close()
# ---------------------------------------------------------------------------
# NOTEBOOKLM_BL override still works after the move to _env.py
# ---------------------------------------------------------------------------
class TestChatBlOverride:
"""Single-source-of-truth for the ``bl`` parameter lives in ``_env.py``.
The ``NOTEBOOKLM_BL`` override must still flow through to the chat URL.
"""
@pytest.mark.asyncio
async def test_custom_bl_env_appears_in_url(
self, httpx_mock, monkeypatch, mock_get_conversation_id
):
monkeypatch.setenv("NOTEBOOKLM_BL", "boq_labs-custom_99999999.00_p0")
auth = AuthTokens(
cookies={"SID": "x"},
csrf_token="csrf",
session_id="sid",
)
httpx_mock.add_response(
url=re.compile(r".*GenerateFreeFormStreamed.*"),
content=_make_answer_response_body(),
method="POST",
)
mock_get_conversation_id()
async with NotebookLMClient(auth) as client:
await client.chat.ask("nb_x", "Q?", source_ids=["s1"])
request = next(
r for r in httpx_mock.get_requests() if "GenerateFreeFormStreamed" in str(r.url)
)
assert _extract_query_param(str(request.url), "bl") == "boq_labs-custom_99999999.00_p0"
@pytest.mark.asyncio
async def test_default_bl_is_pinned_constant(
self, httpx_mock, monkeypatch, mock_get_conversation_id
):
"""With ``NOTEBOOKLM_BL`` unset, the URL falls back to the pinned default.
The expected literal is duplicated here on purpose: importing
``DEFAULT_BL`` from the SUT and asserting equality would be a
tautology — any wrong-value edit to ``_env.DEFAULT_BL`` would still
pass. The literal pin catches that.
"""
monkeypatch.delenv("NOTEBOOKLM_BL", raising=False)
auth = AuthTokens(
cookies={"SID": "x"},
csrf_token="csrf",
session_id="sid",
)
httpx_mock.add_response(
url=re.compile(r".*GenerateFreeFormStreamed.*"),
content=_make_answer_response_body(),
method="POST",
)
mock_get_conversation_id()
async with NotebookLMClient(auth) as client:
await client.chat.ask("nb_x", "Q?", source_ids=["s1"])
request = next(
r for r in httpx_mock.get_requests() if "GenerateFreeFormStreamed" in str(r.url)
)
assert (
_extract_query_param(str(request.url), "bl")
== "boq_labs-tailwind-frontend_20260301.03_p0"
)
# ---------------------------------------------------------------------------
# _build_chat_request direct unit-level coverage
# ---------------------------------------------------------------------------
class TestChatNewConversationLocks:
"""Notebook-scoped locks use the same per-key cache style as conversation locks."""
def _factory(self) -> ChatAPI:
from unittest.mock import MagicMock
loop_guard = MagicMock()
loop_guard.assert_bound_loop = MagicMock()
return ChatAPI(
rpc=MagicMock(),
transport=MagicMock(),
reqid=MagicMock(),
loop_guard=loop_guard,
)
def test_same_notebook_reuses_new_conversation_lock(self):
chat = self._factory()
lock_a = chat._get_new_conversation_lock("nb-1")
lock_b = chat._get_new_conversation_lock("nb-1")
assert lock_a is lock_b
def test_different_notebooks_get_distinct_new_conversation_locks(self):
chat = self._factory()
lock_a = chat._get_new_conversation_lock("nb-1")
lock_b = chat._get_new_conversation_lock("nb-2")
assert lock_a is not lock_b
@pytest.mark.asyncio
async def test_failed_post_ask_hptbtc_lookup_releases_new_conversation_lock(self):
class HptbtcFailureChatAPI(ChatAPI):
def __init__(self, *, lookup_results: list[str | ChatError], **kwargs: Any) -> None:
super().__init__(**kwargs)
self._lookup_results = iter(lookup_results)
self.lookup_count = 0
async def get_conversation_id(self, notebook_id: str) -> str | None:
self.lookup_count += 1
result = next(self._lookup_results)
if isinstance(result, ChatError):
raise result
return result
async def fake_perform_authed_post(*args: Any, **kwargs: Any) -> httpx.Response:
return httpx.Response(
200,
request=httpx.Request("POST", "https://notebooklm.google.com/_/LabsTailwindUi"),
content=_make_answer_response_body(),
)
chat = HptbtcFailureChatAPI(
rpc=SimpleNamespace(),
transport=SimpleNamespace(
perform_authed_post=AsyncMock(side_effect=fake_perform_authed_post)
),
reqid=SimpleNamespace(next_reqid=AsyncMock(side_effect=[100000, 200000])),
loop_guard=SimpleNamespace(assert_bound_loop=lambda: None),
lookup_results=[ChatError("hPTbtc lookup failed"), "conv-after-failure"],
)
new_conversation_lock = chat._get_new_conversation_lock("nb-1")
with pytest.raises(ChatError, match="hPTbtc lookup failed"):
await chat.ask("nb-1", "first ask", source_ids=["s1"])
assert not new_conversation_lock.locked()
result = await asyncio.wait_for(
chat.ask("nb-1", "second ask", source_ids=["s1"]),
timeout=1.0,
)
assert result.conversation_id == "conv-after-failure"
assert result.answer == "Refactor answer is long enough."
assert chat.lookup_count == 2
class TestBuildChatRequestFactory:
"""Direct unit tests for the new ``ChatAPI._build_chat_request`` factory.
Bypassing the full ``ask`` plumbing keeps these checks focused on the
URL/body assembly contract that ``chat_aware_authed_post`` relies on.
"""
def _factory(self) -> ChatAPI:
# Wave 8 of session-decoupling (ADR-0014 Rule 2 Corollary):
# ``ChatAPI`` takes direct collaborators by keyword arg. Pure
# ``_build_chat_request`` exercise — none of these collaborators
# are touched, so they are bare ``MagicMock()`` placeholders.
from unittest.mock import MagicMock
return ChatAPI(
rpc=MagicMock(),
transport=MagicMock(),
reqid=MagicMock(),
loop_guard=MagicMock(),
)
def test_build_request_omits_authuser_for_default_profile(self):
chat = self._factory()
snapshot = AuthSnapshot(
csrf_token="csrf",
session_id="sid",
authuser=0,
account_email=None,
)
url, body, headers = chat._build_chat_request(
snapshot=snapshot,
notebook_id="nb_x",
question="Q?",
source_ids=["s1"],
conversation_history=None,
conversation_id="conv-1",
reqid=200000,
)
assert _extract_query_param(url, "authuser") is None
assert _extract_query_param(url, "_reqid") == "200000"
assert "at=csrf" in body
assert headers == {}
def test_build_request_authuser_email_wins_over_index(self):
chat = self._factory()
snapshot = AuthSnapshot(
csrf_token="csrf",
session_id="sid",
authuser=5,
account_email="me@example.com",
)
url, _, _ = chat._build_chat_request(
snapshot=snapshot,
notebook_id="nb_x",
question="Q?",
source_ids=["s1"],
conversation_history=None,
conversation_id="conv-1",
reqid=300000,
)
# Email is preferred when present — matches ``format_authuser_value``.
assert _extract_query_param(url, "authuser") == "me@example.com"
def test_build_request_omits_at_when_csrf_blank(self):
chat = self._factory()
snapshot = AuthSnapshot(
csrf_token="",
session_id="sid",
authuser=0,
account_email=None,
)
_, body, _ = chat._build_chat_request(
snapshot=snapshot,
notebook_id="nb_x",
question="Q?",
source_ids=["s1"],
conversation_history=None,
conversation_id="conv-1",
reqid=400000,
)
assert "at=" not in body
def test_build_request_source_encoding_is_triple_nested(self):
chat = self._factory()
snapshot = AuthSnapshot(
csrf_token="csrf",
session_id="sid",
authuser=0,
account_email=None,
)
_, body, _ = chat._build_chat_request(
snapshot=snapshot,
notebook_id="nb_x",
question="Q?",
source_ids=["s1", "s2"],
conversation_history=None,
conversation_id="conv-1",
reqid=500000,
)
match = re.search(r"f\.req=([^&]+)", body)
assert match is not None
f_req_data: list[Any] = json.loads(unquote(match.group(1)))
params: list[Any] = json.loads(f_req_data[1])
assert params[0] == [[["s1"]], [["s2"]]]