Skip to content

Commit 5dd306a

Browse files
test(components): merge correctness tests into test_component_index.py
Move the 6 regression tests for the post-review fixes into the existing test_component_index.py and drop the standalone file. Reuses the file's existing _fake_settings_service / _reset_component_cache_singleton helpers.
1 parent 08c086e commit 5dd306a

2 files changed

Lines changed: 192 additions & 221 deletions

File tree

src/lfx/tests/unit/test_component_index.py

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import json
66
import threading
77
from pathlib import Path
8-
from unittest.mock import AsyncMock, Mock, patch
8+
from typing import Any
9+
from unittest.mock import AsyncMock, MagicMock, Mock, patch
910

1011
import orjson
1112
import pytest
@@ -1409,3 +1410,193 @@ def test_cache_hit_perf_under_500ms(self, tmp_path, monkeypatch, prebuilt_cache_
14091410
f"cache-hit path took {elapsed * 1000:.1f}ms, must be under 500ms. "
14101411
"Regression likely: check for double-read of cache file or residual walk of lfx.components."
14111412
)
1413+
1414+
1415+
# ---------------------------------------------------------------------------
1416+
# Regression tests for the post-review correctness fixes (one per fix).
1417+
# ---------------------------------------------------------------------------
1418+
1419+
1420+
def _build_cache_blob(version: str, entries: list, *, valid_sha: bool = True) -> bytes:
1421+
"""Serialize a cache file matching the on-disk format.
1422+
1423+
With valid_sha=False the sha256 field is set to a deterministic but wrong value.
1424+
"""
1425+
blob: dict[str, Any] = {
1426+
"version": version,
1427+
"metadata": {"num_modules": len(entries), "num_components": sum(len(c) for _, c in entries)},
1428+
"entries": entries,
1429+
}
1430+
payload = orjson.dumps(blob, option=orjson.OPT_SORT_KEYS)
1431+
blob["sha256"] = hashlib.sha256(payload).hexdigest() if valid_sha else "0" * 64
1432+
return orjson.dumps(blob)
1433+
1434+
1435+
@pytest.mark.asyncio
1436+
class TestCorrectnessFixes:
1437+
"""One test per post-review correctness fix in lfx.interface.components."""
1438+
1439+
async def test_tampered_sha_does_not_short_circuit(self, tmp_path, monkeypatch):
1440+
"""A version-matched but SHA-tampered cache must fall through to the rebuild path."""
1441+
from lfx.interface import components as ci
1442+
1443+
cache_file = tmp_path / "component_index.json"
1444+
installed = "test-1.0"
1445+
entries = [["cat1", {"comp1": {"template": {}, "display_name": "C"}}]]
1446+
cache_file.write_bytes(_build_cache_blob(installed, entries, valid_sha=False))
1447+
1448+
monkeypatch.setattr(ci, "_get_cache_path", lambda: cache_file)
1449+
monkeypatch.setattr("importlib.metadata.version", lambda _name: installed)
1450+
_reset_component_cache_singleton(monkeypatch)
1451+
1452+
warning_mock = MagicMock()
1453+
monkeypatch.setattr(ci.logger, "warning", warning_mock)
1454+
1455+
rebuild_mock = AsyncMock(return_value={"components": {"rebuilt": {"x": {}}}})
1456+
monkeypatch.setattr(ci, "import_langflow_components", rebuild_mock)
1457+
monkeypatch.setattr(ci, "_determine_loading_strategy", AsyncMock(return_value={}))
1458+
1459+
await ci.get_and_cache_all_types_dict(_fake_settings_service())
1460+
1461+
rebuild_mock.assert_awaited_once()
1462+
sha_warns = [
1463+
call for call in warning_mock.call_args_list if call.args and "SHA256 integrity" in str(call.args[0])
1464+
]
1465+
assert len(sha_warns) == 1, f"expected 1 SHA256 integrity warning, got: {warning_mock.call_args_list!r}"
1466+
1467+
async def test_stale_version_cache_is_deleted(self, tmp_path, monkeypatch):
1468+
"""When peek detects a version mismatch, the stale cache must be unlinked."""
1469+
from lfx.interface import components as ci
1470+
1471+
cache_file = tmp_path / "component_index.json"
1472+
cache_file.write_bytes(_build_cache_blob("old-1.0", []))
1473+
1474+
monkeypatch.setattr(ci, "_get_cache_path", lambda: cache_file)
1475+
monkeypatch.setattr("importlib.metadata.version", lambda _name: "new-2.0")
1476+
_reset_component_cache_singleton(monkeypatch)
1477+
1478+
warning_mock = MagicMock()
1479+
monkeypatch.setattr(ci.logger, "warning", warning_mock)
1480+
monkeypatch.setattr(ci, "import_langflow_components", AsyncMock(return_value={"components": {}}))
1481+
monkeypatch.setattr(ci, "_determine_loading_strategy", AsyncMock(return_value={}))
1482+
1483+
await ci.get_and_cache_all_types_dict(_fake_settings_service())
1484+
1485+
assert not cache_file.exists(), "stale cache file must be unlinked"
1486+
stale_warns = [
1487+
call for call in warning_mock.call_args_list if call.args and "Stale component cache" in str(call.args[0])
1488+
]
1489+
assert len(stale_warns) == 1
1490+
1491+
async def test_corrupt_json_cache_emits_warning_and_rebuilds(self, tmp_path, monkeypatch):
1492+
"""Corrupt cache file must warn and fall through, not silently swallow."""
1493+
from lfx.interface import components as ci
1494+
1495+
cache_file = tmp_path / "component_index.json"
1496+
cache_file.write_bytes(b"this is not json at all {{{ garbage")
1497+
1498+
monkeypatch.setattr(ci, "_get_cache_path", lambda: cache_file)
1499+
monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.0.0")
1500+
_reset_component_cache_singleton(monkeypatch)
1501+
1502+
warning_mock = MagicMock()
1503+
monkeypatch.setattr(ci.logger, "warning", warning_mock)
1504+
1505+
rebuild_mock = AsyncMock(return_value={"components": {}})
1506+
monkeypatch.setattr(ci, "import_langflow_components", rebuild_mock)
1507+
monkeypatch.setattr(ci, "_determine_loading_strategy", AsyncMock(return_value={}))
1508+
1509+
await ci.get_and_cache_all_types_dict(_fake_settings_service())
1510+
1511+
rebuild_mock.assert_awaited_once()
1512+
peek_warns = [
1513+
call
1514+
for call in warning_mock.call_args_list
1515+
if call.args and "Component cache peek failed" in str(call.args[0])
1516+
]
1517+
assert len(peek_warns) == 1
1518+
1519+
async def test_save_generated_index_oserror_logs_at_warning(self, tmp_path, monkeypatch):
1520+
"""OSError on cache write must surface at warning so cold-start regressions are visible."""
1521+
from lfx.interface import components as ci
1522+
1523+
cache_file = tmp_path / "component_index.json"
1524+
monkeypatch.setattr(ci, "_get_cache_path", lambda: cache_file)
1525+
1526+
original_write_bytes = type(cache_file).write_bytes
1527+
1528+
def deny(self, *_a, **_kw):
1529+
if str(self).endswith(".tmp"):
1530+
msg = "simulated read-only mount"
1531+
raise PermissionError(msg)
1532+
return original_write_bytes(self, *_a, **_kw)
1533+
1534+
monkeypatch.setattr(type(cache_file), "write_bytes", deny)
1535+
1536+
warning_mock = MagicMock()
1537+
monkeypatch.setattr(ci.logger, "warning", warning_mock)
1538+
1539+
_save_generated_index({"cat": {"comp": {"template": {}}}})
1540+
1541+
assert warning_mock.call_count == 1
1542+
assert "PermissionError" in str(warning_mock.call_args.args[0])
1543+
1544+
async def test_selective_dev_mode_empty_result_emits_warning(self, monkeypatch):
1545+
"""Zero-component result in selective dev mode must warn instead of returning silently."""
1546+
from lfx.interface import components as ci
1547+
1548+
warning_mock = AsyncMock()
1549+
monkeypatch.setattr(ci.logger, "awarning", warning_mock)
1550+
monkeypatch.setattr(ci, "_load_from_index_or_cache", AsyncMock(return_value=({}, None)))
1551+
monkeypatch.setattr(ci, "_load_components_dynamically", AsyncMock(return_value={}))
1552+
1553+
modules, source = await ci._load_selective_dev_mode(None, ["nonexistent"])
1554+
1555+
assert modules == {}
1556+
assert source == "dynamic"
1557+
empty_warns = [
1558+
call for call in warning_mock.call_args_list if call.args and "produced 0 components" in str(call.args[0])
1559+
]
1560+
assert len(empty_warns) == 1
1561+
1562+
async def test_load_components_dynamically_emits_aggregate_failure_summary(self, monkeypatch):
1563+
"""Partial-failure load must emit one aggregate log with count + types histogram."""
1564+
from lfx.interface import components as ci
1565+
1566+
modnames = [
1567+
"lfx.components.cat1.mod_a",
1568+
"lfx.components.cat1.mod_b",
1569+
"lfx.components.cat2.mod_c",
1570+
]
1571+
1572+
def fake_walk_packages(*_args, **_kwargs):
1573+
for name in modnames:
1574+
yield (None, name, False)
1575+
1576+
def fake_process(modname: str):
1577+
if modname.endswith("mod_a"):
1578+
msg = "boom A"
1579+
raise ImportError(msg)
1580+
if modname.endswith("mod_b"):
1581+
msg = "boom B"
1582+
raise ValueError(msg)
1583+
return ("cat2", {"mod_c": {"template": {}, "display_name": "C"}})
1584+
1585+
monkeypatch.setattr(ci.pkgutil, "walk_packages", fake_walk_packages)
1586+
monkeypatch.setattr(ci, "_process_single_module", fake_process)
1587+
1588+
error_mock = AsyncMock()
1589+
monkeypatch.setattr(ci.logger, "aerror", error_mock)
1590+
monkeypatch.setattr(ci.logger, "awarning", AsyncMock())
1591+
1592+
result = await ci._load_components_dynamically(target_modules=None)
1593+
1594+
assert "cat2" in result, "non-failing module should still be loaded"
1595+
aggregate_calls = [
1596+
call for call in error_mock.call_args_list if call.args and "modules failed" in str(call.args[0])
1597+
]
1598+
assert len(aggregate_calls) == 1
1599+
msg = str(aggregate_calls[0].args[0])
1600+
assert "2 of 3" in msg
1601+
assert "ImportError" in msg
1602+
assert "ValueError" in msg

0 commit comments

Comments
 (0)