mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-09 12:30:37 +00:00
* fix(community): strip provider API keys before they reach a header GroundRoute (from GROUNDROUTE_API_KEY), InfoQuest, Tavily, Exa, Jina, Firecrawl, fastCRW and Crawl4AI put the configured or environment key into an HTTP header as read. A key read from a file keeps its trailing newline (a Kubernetes Secret created with --from-file and mounted through envFrom), and the transport refuses it with an error that quotes the whole header: h11's "Illegal header value b'Bearer <key>\n'" or requests' "... in header value: '<key>\n'". The providers returned that message as the tool result, so the key reached the model and the checkpointed conversation, and GroundRoute, InfoQuest, Jina and Crawl4AI also wrote it to the error log. Resolve each key through provider_credentials.resolve_api_key, as the other bundled providers already strip theirs. Tavily, Exa and Firecrawl now read their environment fallback themselves instead of leaving it to the SDK, which would read the raw value again. A blank configured key now falls back to the environment, as it already does for GroundRoute and the stripping providers. * docs(changelog): reference #7981 for the provider API key fix * fix(community): keep fastCRW from sending the Firecrawl key fastCRW builds the Firecrawl SDK client, and the SDK reads FIRECRAWL_API_KEY whenever api_key is None. Once a blank configured key resolved to None, a fastCRW config with api_key: "" and no CRW_API_KEY sent the Firecrawl key to the fastCRW endpoint. With no api_key configured at all, main already did the same. Pass "" when fastCRW resolves no key of its own; the SDK falls back only on None and sends no Authorization header for an empty key. A loopback test pins the empty, blank and absent cases with FIRECRAWL_API_KEY set. * fix(community): keep a blank environment key away from the SDKs Tavily, Exa and Firecrawl resolved their environment fallback themselves, but passed None when it was blank, and each SDK then re-read the same variable unstripped. Tavily and Firecrawl now pass "", which the SDKs treat as a missing key; Exa sends "" as is, so it fails before any request instead. The fastCRW comment records that only the Firecrawl SDK's v2 client honours "", and the fastCRW regression test now covers web_fetch as well as web_search, so a move to the v1 client would fail it. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
434 lines
19 KiB
Python
434 lines
19 KiB
Python
"""Unit tests for the Firecrawl community tools."""
|
|
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def public_dns():
|
|
"""Keep mocked tool calls offline while exercising the real URL safety check."""
|
|
with patch(
|
|
"deerflow.community.url_safety.resolve_host_addresses",
|
|
return_value=[ipaddress.ip_address("93.184.216.34")],
|
|
):
|
|
yield
|
|
|
|
|
|
class TestWebSearchTool:
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.anyio
|
|
async def test_search_uses_web_search_config(self, mock_get_app_config, mock_firecrawl_cls):
|
|
search_config = MagicMock()
|
|
search_config.model_extra = {"api_key": "firecrawl-search-key", "max_results": 7}
|
|
mock_get_app_config.return_value.get_tool_config.return_value = search_config
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.web = [
|
|
MagicMock(title="Result", url="https://example.com", description="Snippet"),
|
|
]
|
|
mock_firecrawl_cls.return_value.search = AsyncMock(return_value=mock_result)
|
|
|
|
from deerflow.community.firecrawl.tools import web_search_tool
|
|
|
|
result = await web_search_tool.ainvoke({"query": "test query"})
|
|
|
|
assert json.loads(result) == [
|
|
{
|
|
"title": "Result",
|
|
"url": "https://example.com",
|
|
"snippet": "Snippet",
|
|
}
|
|
]
|
|
mock_get_app_config.return_value.get_tool_config.assert_called_with("web_search")
|
|
mock_firecrawl_cls.assert_called_once_with(api_key="firecrawl-search-key")
|
|
mock_firecrawl_cls.return_value.search.assert_called_once_with("test query", limit=7)
|
|
|
|
|
|
class TestWebFetchTool:
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.usefixtures("public_dns")
|
|
@pytest.mark.anyio
|
|
async def test_fetch_uses_web_fetch_config(self, mock_get_app_config, mock_firecrawl_cls):
|
|
fetch_config = MagicMock()
|
|
fetch_config.model_extra = {"api_key": "firecrawl-fetch-key"}
|
|
|
|
def get_tool_config(name):
|
|
if name == "web_fetch":
|
|
return fetch_config
|
|
return None
|
|
|
|
mock_get_app_config.return_value.get_tool_config.side_effect = get_tool_config
|
|
|
|
mock_scrape_result = MagicMock()
|
|
mock_scrape_result.markdown = "Fetched markdown"
|
|
mock_scrape_result.metadata = MagicMock(title="Fetched Page")
|
|
mock_firecrawl_cls.return_value.scrape = AsyncMock(return_value=mock_scrape_result)
|
|
|
|
from deerflow.community.firecrawl.tools import web_fetch_tool
|
|
|
|
result = await web_fetch_tool.ainvoke({"url": "https://example.com"})
|
|
|
|
assert result == "# Fetched Page\n\nFetched markdown"
|
|
mock_get_app_config.return_value.get_tool_config.assert_any_call("web_fetch")
|
|
mock_firecrawl_cls.assert_called_once_with(api_key="firecrawl-fetch-key")
|
|
mock_firecrawl_cls.return_value.scrape.assert_called_once_with(
|
|
"https://example.com",
|
|
formats=["markdown"],
|
|
)
|
|
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.usefixtures("public_dns")
|
|
@pytest.mark.anyio
|
|
async def test_fetch_builds_client_from_the_validated_config_snapshot(self, mock_get_app_config, mock_firecrawl_cls):
|
|
# The endpoint screen reads the config once; a later read returning a
|
|
# different revision must not leak its key into the screened client.
|
|
config_a = MagicMock()
|
|
config_a.model_extra = {
|
|
"base_url": "http://192.168.0.47:3002",
|
|
"network_isolation_confirmed": True,
|
|
"api_key": "key-a",
|
|
}
|
|
config_b = MagicMock()
|
|
config_b.model_extra = {
|
|
"base_url": "http://192.168.0.99:3002",
|
|
"network_isolation_confirmed": True,
|
|
"api_key": "key-b",
|
|
}
|
|
mock_get_app_config.return_value.get_tool_config.side_effect = [config_a, config_b]
|
|
|
|
mock_scrape_result = MagicMock()
|
|
mock_scrape_result.markdown = "Fetched markdown"
|
|
mock_scrape_result.metadata = MagicMock(title="Fetched Page")
|
|
mock_firecrawl_cls.return_value.scrape = AsyncMock(return_value=mock_scrape_result)
|
|
|
|
from deerflow.community.firecrawl.tools import web_fetch_tool
|
|
|
|
result = await web_fetch_tool.ainvoke({"url": "https://example.com"})
|
|
|
|
assert result == "# Fetched Page\n\nFetched markdown"
|
|
mock_firecrawl_cls.assert_called_once_with(api_key="key-a", api_url="http://192.168.0.47:3002")
|
|
|
|
|
|
class TestFirecrawlBaseUrl:
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.usefixtures("public_dns")
|
|
@pytest.mark.anyio
|
|
async def test_fetch_passes_base_url_as_api_url(self, mock_get_app_config, mock_firecrawl_cls):
|
|
fetch_config = MagicMock()
|
|
fetch_config.model_extra = {"base_url": "http://192.168.0.47:3002", "network_isolation_confirmed": True}
|
|
|
|
def get_tool_config(name):
|
|
if name == "web_fetch":
|
|
return fetch_config
|
|
return None
|
|
|
|
mock_get_app_config.return_value.get_tool_config.side_effect = get_tool_config
|
|
|
|
mock_scrape_result = MagicMock()
|
|
mock_scrape_result.markdown = "Fetched markdown"
|
|
mock_scrape_result.metadata = MagicMock(title="Fetched Page")
|
|
mock_firecrawl_cls.return_value.scrape = AsyncMock(return_value=mock_scrape_result)
|
|
|
|
from deerflow.community.firecrawl.tools import web_fetch_tool
|
|
|
|
result = await web_fetch_tool.ainvoke({"url": "https://example.com"})
|
|
|
|
assert result == "# Fetched Page\n\nFetched markdown"
|
|
mock_firecrawl_cls.assert_called_once_with(api_key="", api_url="http://192.168.0.47:3002")
|
|
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.anyio
|
|
async def test_search_passes_base_url_and_api_key(self, mock_get_app_config, mock_firecrawl_cls):
|
|
search_config = MagicMock()
|
|
search_config.model_extra = {
|
|
"api_key": "firecrawl-key",
|
|
"base_url": "http://192.168.0.47:3002",
|
|
"max_results": 5,
|
|
}
|
|
mock_get_app_config.return_value.get_tool_config.return_value = search_config
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.web = []
|
|
mock_firecrawl_cls.return_value.search = AsyncMock(return_value=mock_result)
|
|
|
|
from deerflow.community.firecrawl.tools import web_search_tool
|
|
|
|
await web_search_tool.ainvoke({"query": "test query"})
|
|
|
|
mock_firecrawl_cls.assert_called_once_with(api_key="firecrawl-key", api_url="http://192.168.0.47:3002")
|
|
|
|
|
|
class TestPerCallClientTeardown:
|
|
"""Every tool call builds a fresh client; its pooled async HTTP client must be closed."""
|
|
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.anyio
|
|
async def test_search_closes_the_per_call_client(self, mock_get_app_config, mock_firecrawl_cls):
|
|
search_config = MagicMock()
|
|
search_config.model_extra = {"api_key": "firecrawl-search-key"}
|
|
mock_get_app_config.return_value.get_tool_config.return_value = search_config
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.web = []
|
|
mock_firecrawl_cls.return_value.search = AsyncMock(return_value=mock_result)
|
|
mock_firecrawl_cls.return_value._v2_client.async_http_client.close = AsyncMock()
|
|
|
|
from deerflow.community.firecrawl.tools import web_search_tool
|
|
|
|
await web_search_tool.ainvoke({"query": "test query"})
|
|
|
|
mock_firecrawl_cls.return_value._v2_client.async_http_client.close.assert_awaited_once()
|
|
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.usefixtures("public_dns")
|
|
@pytest.mark.anyio
|
|
async def test_fetch_closes_the_per_call_client(self, mock_get_app_config, mock_firecrawl_cls):
|
|
fetch_config = MagicMock()
|
|
fetch_config.model_extra = {"api_key": "firecrawl-fetch-key"}
|
|
mock_get_app_config.return_value.get_tool_config.return_value = fetch_config
|
|
|
|
mock_scrape_result = MagicMock()
|
|
mock_scrape_result.markdown = "Fetched markdown"
|
|
mock_scrape_result.metadata = MagicMock(title="Fetched Page")
|
|
mock_firecrawl_cls.return_value.scrape = AsyncMock(return_value=mock_scrape_result)
|
|
mock_firecrawl_cls.return_value._v2_client.async_http_client.close = AsyncMock()
|
|
|
|
from deerflow.community.firecrawl.tools import web_fetch_tool
|
|
|
|
await web_fetch_tool.ainvoke({"url": "https://example.com"})
|
|
|
|
mock_firecrawl_cls.return_value._v2_client.async_http_client.close.assert_awaited_once()
|
|
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.anyio
|
|
async def test_search_closes_the_per_call_client_when_search_raises(self, mock_get_app_config, mock_firecrawl_cls):
|
|
mock_get_app_config.return_value.get_tool_config.return_value = None
|
|
client = mock_firecrawl_cls.return_value
|
|
client.search = AsyncMock(side_effect=RuntimeError("search failed"))
|
|
client._v2_client.async_http_client.close = AsyncMock()
|
|
|
|
from deerflow.community.firecrawl.tools import web_search_tool
|
|
|
|
result = await web_search_tool.ainvoke({"query": "test query"})
|
|
|
|
assert result == "Error: search failed"
|
|
client._v2_client.async_http_client.close.assert_awaited_once()
|
|
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.usefixtures("public_dns")
|
|
@pytest.mark.anyio
|
|
async def test_fetch_closes_the_per_call_client_when_scrape_raises(self, mock_get_app_config, mock_firecrawl_cls):
|
|
mock_get_app_config.return_value.get_tool_config.return_value = None
|
|
client = mock_firecrawl_cls.return_value
|
|
client.scrape = AsyncMock(side_effect=RuntimeError("scrape failed"))
|
|
client._v2_client.async_http_client.close = AsyncMock()
|
|
|
|
from deerflow.community.firecrawl.tools import web_fetch_tool
|
|
|
|
result = await web_fetch_tool.ainvoke({"url": "https://example.com"})
|
|
|
|
assert result == "Error: scrape failed"
|
|
client._v2_client.async_http_client.close.assert_awaited_once()
|
|
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.usefixtures("public_dns")
|
|
@pytest.mark.anyio
|
|
async def test_close_failure_does_not_hide_scrape_error(self, mock_get_app_config, mock_firecrawl_cls, caplog):
|
|
mock_get_app_config.return_value.get_tool_config.return_value = None
|
|
client = mock_firecrawl_cls.return_value
|
|
client.scrape = AsyncMock(side_effect=RuntimeError("scrape failed"))
|
|
client._v2_client.async_http_client.close = AsyncMock(side_effect=RuntimeError("close failed"))
|
|
|
|
from deerflow.community.firecrawl.tools import web_fetch_tool
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.firecrawl.tools"):
|
|
result = await web_fetch_tool.ainvoke({"url": "https://example.com"})
|
|
|
|
assert result == "Error: scrape failed"
|
|
client._v2_client.async_http_client.close.assert_awaited_once()
|
|
assert "Failed to close the Firecrawl async HTTP pool" in caplog.text
|
|
|
|
@pytest.mark.anyio
|
|
async def test_teardown_reports_a_pool_without_close(self, caplog):
|
|
from deerflow.community.firecrawl.tools import _aclose_firecrawl_client
|
|
|
|
client = SimpleNamespace(_v2_client=SimpleNamespace(async_http_client=SimpleNamespace()))
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.firecrawl.tools"):
|
|
await _aclose_firecrawl_client(client)
|
|
|
|
assert "Firecrawl async HTTP pool has no close method" in caplog.text
|
|
|
|
@pytest.mark.anyio
|
|
async def test_teardown_accepts_a_synchronous_close(self, caplog):
|
|
from deerflow.community.firecrawl.tools import _aclose_firecrawl_client
|
|
|
|
close = Mock()
|
|
client = SimpleNamespace(_v2_client=SimpleNamespace(async_http_client=SimpleNamespace(close=close)))
|
|
|
|
with caplog.at_level(logging.DEBUG, logger="deerflow.community.firecrawl.tools"):
|
|
await _aclose_firecrawl_client(client)
|
|
|
|
close.assert_called_once_with()
|
|
assert "Failed to close the Firecrawl async HTTP pool" not in caplog.text
|
|
|
|
@pytest.mark.anyio
|
|
async def test_teardown_closes_the_real_sdk_pool(self):
|
|
from firecrawl import AsyncFirecrawlApp as RealApp
|
|
|
|
from deerflow.community.firecrawl.tools import _aclose_firecrawl_client
|
|
|
|
client = RealApp(api_key="test-key")
|
|
pooled = getattr(getattr(client, "_v2_client", None), "async_http_client", None)
|
|
inner = getattr(pooled, "_client", None)
|
|
if inner is None:
|
|
# The helper deliberately treats a missing or reshaped private
|
|
# layout as a no-op (the declared >=1.15.0 range includes such
|
|
# versions); skip instead of hard-asserting one locked version's
|
|
# internals.
|
|
pytest.skip("locked firecrawl-py private pool layout not present")
|
|
|
|
assert inner.is_closed is False
|
|
|
|
await _aclose_firecrawl_client(client)
|
|
|
|
assert inner.is_closed is True
|
|
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.anyio
|
|
async def test_teardown_survives_a_client_without_v2_attributes(self, mock_get_app_config, mock_firecrawl_cls):
|
|
"""Older SDKs in the declared range have no _v2_client; teardown must be a no-op."""
|
|
search_config = MagicMock()
|
|
search_config.model_extra = {"api_key": "firecrawl-search-key"}
|
|
mock_get_app_config.return_value.get_tool_config.return_value = search_config
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.web = []
|
|
mock_firecrawl_cls.return_value.search = AsyncMock(return_value=mock_result)
|
|
del mock_firecrawl_cls.return_value._v2_client
|
|
|
|
from deerflow.community.firecrawl.tools import web_search_tool
|
|
|
|
result = await web_search_tool.ainvoke({"query": "test query"})
|
|
|
|
assert result == "[]"
|
|
|
|
|
|
class TestFirecrawlBackendIsolation:
|
|
"""Fail-closed delegated-backend SSRF guard (issue #5970)."""
|
|
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.usefixtures("public_dns")
|
|
@pytest.mark.anyio
|
|
async def test_fetch_fails_closed_on_self_hosted_backend(self, mock_get_app_config, mock_firecrawl_cls):
|
|
fetch_config = MagicMock()
|
|
fetch_config.model_extra = {"base_url": "http://localhost:3000"}
|
|
mock_get_app_config.return_value.get_tool_config.return_value = fetch_config
|
|
|
|
from deerflow.community.firecrawl.tools import web_fetch_tool
|
|
|
|
result = await web_fetch_tool.ainvoke({"url": "https://example.com"})
|
|
|
|
assert "network_isolation_confirmed" in result
|
|
mock_firecrawl_cls.assert_not_called()
|
|
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.usefixtures("public_dns")
|
|
@pytest.mark.anyio
|
|
async def test_fetch_allows_self_hosted_backend_when_confirmed(self, mock_get_app_config, mock_firecrawl_cls):
|
|
fetch_config = MagicMock()
|
|
fetch_config.model_extra = {"base_url": "http://localhost:3000", "network_isolation_confirmed": True}
|
|
mock_get_app_config.return_value.get_tool_config.return_value = fetch_config
|
|
|
|
mock_scrape_result = MagicMock()
|
|
mock_scrape_result.markdown = "ok"
|
|
mock_scrape_result.metadata = MagicMock(title="T")
|
|
mock_firecrawl_cls.return_value.scrape = AsyncMock(return_value=mock_scrape_result)
|
|
|
|
from deerflow.community.firecrawl.tools import web_fetch_tool
|
|
|
|
result = await web_fetch_tool.ainvoke({"url": "https://example.com"})
|
|
|
|
assert result == "# T\n\nok"
|
|
mock_firecrawl_cls.return_value.scrape.assert_called_once_with("https://example.com", formats=["markdown"])
|
|
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.usefixtures("public_dns")
|
|
@pytest.mark.anyio
|
|
async def test_fetch_allows_public_backend(self, mock_get_app_config, mock_firecrawl_cls):
|
|
mock_get_app_config.return_value.get_tool_config.return_value = None
|
|
|
|
mock_scrape_result = MagicMock()
|
|
mock_scrape_result.markdown = "ok"
|
|
mock_scrape_result.metadata = MagicMock(title="T")
|
|
mock_firecrawl_cls.return_value.scrape = AsyncMock(return_value=mock_scrape_result)
|
|
|
|
from deerflow.community.firecrawl.tools import web_fetch_tool
|
|
|
|
result = await web_fetch_tool.ainvoke({"url": "https://example.com"})
|
|
|
|
assert result == "# T\n\nok"
|
|
|
|
|
|
# `None` is itself a configured value (`max_results:` with nothing after it in YAML), so an absent key
|
|
# needs its own sentinel — otherwise the key-present case is never built and never tested.
|
|
_OMITTED = object()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("configured", "expected_limit", "warns"),
|
|
[
|
|
pytest.param(True, 5, True, id="bool"),
|
|
pytest.param(0, 5, True, id="zero"),
|
|
pytest.param(-3, 5, True, id="negative"),
|
|
pytest.param(3.5, 5, True, id="fractional"),
|
|
pytest.param("many", 5, True, id="non-numeric-string"),
|
|
pytest.param("8", 8, False, id="integer-string"),
|
|
pytest.param(7, 7, False, id="plain-int"),
|
|
pytest.param(None, 5, True, id="explicit-null"),
|
|
pytest.param(_OMITTED, 5, False, id="omitted"),
|
|
],
|
|
)
|
|
@patch("deerflow.community.firecrawl.tools.AsyncFirecrawlApp")
|
|
@patch("deerflow.community.firecrawl.tools.get_app_config")
|
|
@pytest.mark.anyio
|
|
async def test_search_normalizes_max_results_before_calling_the_client(mock_get_app_config, mock_firecrawl_cls, caplog, configured, expected_limit, warns):
|
|
search_config = MagicMock()
|
|
extra: dict[str, object] = {"api_key": "firecrawl-search-key"}
|
|
if configured is not _OMITTED:
|
|
extra["max_results"] = configured
|
|
search_config.model_extra = extra
|
|
mock_get_app_config.return_value.get_tool_config.return_value = search_config
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.web = []
|
|
mock_firecrawl_cls.return_value.search = AsyncMock(return_value=mock_result)
|
|
|
|
from deerflow.community.firecrawl.tools import web_search_tool
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.firecrawl.tools"):
|
|
result = await web_search_tool.ainvoke({"query": "q"})
|
|
|
|
assert result == "[]"
|
|
mock_firecrawl_cls.return_value.search.assert_called_once_with("q", limit=expected_limit)
|
|
assert ("Invalid Firecrawl max_results" in caplog.text) is warns
|