|
5 | 5 | import json |
6 | 6 | import threading |
7 | 7 | 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 |
9 | 10 |
|
10 | 11 | import orjson |
11 | 12 | import pytest |
@@ -1409,3 +1410,193 @@ def test_cache_hit_perf_under_500ms(self, tmp_path, monkeypatch, prebuilt_cache_ |
1409 | 1410 | f"cache-hit path took {elapsed * 1000:.1f}ms, must be under 500ms. " |
1410 | 1411 | "Regression likely: check for double-read of cache file or residual walk of lfx.components." |
1411 | 1412 | ) |
| 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