From f8c20a1512d1d0cfa0ba3ea7bc23eb414d43bc61 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 16 May 2026 17:38:17 +0800 Subject: [PATCH 1/2] test: build SDK quality test suite --- .github/workflows/test.yml | 46 ++ pyproject.toml | 16 + tests/__init__.py | 1 + .../definition/components/test_components.py | 97 ++++ tests/api/definition/test_manifest.py | 124 +++++ .../entities/builtin/test_command_context.py | 85 +++ .../entities/builtin/test_platform_logger.py | 23 + .../entities/builtin/test_provider_message.py | 111 ++++ tests/api/entities/builtin/test_rag_models.py | 133 +++++ tests/api/entities/test_context.py | 163 ++++++ tests/api/proxies/test_base.py | 11 + tests/api/proxies/test_langbot_api.py | 218 ++++++++ tests/api/proxies/test_query_based_api.py | 96 ++++ tests/cli/run/test_controller.py | 172 ++++++ tests/cli/run/test_runtime_handler.py | 338 ++++++++++++ tests/cli/test_buildplugin.py | 81 +++ tests/cli/test_gencomponent.py | 90 +++ tests/cli/test_i18n_form.py | 63 +++ tests/cli/test_initplugin.py | 102 ++++ tests/cli/test_login.py | 516 ++++++++++++++++++ tests/cli/test_logout_publish.py | 184 +++++++ tests/cli/test_page_components.py | 63 +++ tests/cli/test_renderer.py | 64 +++ tests/cli/test_runplugin.py | 257 +++++++++ tests/entities/io/test_protocol.py | 88 +++ tests/helpers/__init__.py | 1 + tests/helpers/protocol.py | 120 ++++ tests/runtime/helper/test_marketplace.py | 131 +++++ tests/runtime/helper/test_pkgmgr.py | 109 ++++ .../io/handlers/test_control_handler.py | 452 +++++++++++++++ .../io/handlers/test_import_contracts.py | 30 + .../io/handlers/test_plugin_handler.py | 317 +++++++++++ tests/runtime/io/test_connections.py | 148 +++++ tests/runtime/io/test_controllers.py | 193 +++++++ tests/runtime/io/test_handler.py | 232 ++++++++ tests/runtime/plugin/test_container.py | 130 +++++ tests/runtime/plugin/test_manager.py | 510 +++++++++++++++++ tests/runtime/test_app.py | 389 +++++++++++++ tests/utils/test_discovery.py | 125 +++++ tests/utils/test_importutil.py | 68 +++ tests/utils/test_platform.py | 9 + 41 files changed, 6106 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 tests/__init__.py create mode 100644 tests/api/definition/components/test_components.py create mode 100644 tests/api/definition/test_manifest.py create mode 100644 tests/api/entities/builtin/test_command_context.py create mode 100644 tests/api/entities/builtin/test_platform_logger.py create mode 100644 tests/api/entities/builtin/test_provider_message.py create mode 100644 tests/api/entities/builtin/test_rag_models.py create mode 100644 tests/api/entities/test_context.py create mode 100644 tests/api/proxies/test_base.py create mode 100644 tests/api/proxies/test_langbot_api.py create mode 100644 tests/api/proxies/test_query_based_api.py create mode 100644 tests/cli/run/test_controller.py create mode 100644 tests/cli/run/test_runtime_handler.py create mode 100644 tests/cli/test_buildplugin.py create mode 100644 tests/cli/test_gencomponent.py create mode 100644 tests/cli/test_i18n_form.py create mode 100644 tests/cli/test_initplugin.py create mode 100644 tests/cli/test_login.py create mode 100644 tests/cli/test_logout_publish.py create mode 100644 tests/cli/test_page_components.py create mode 100644 tests/cli/test_renderer.py create mode 100644 tests/cli/test_runplugin.py create mode 100644 tests/entities/io/test_protocol.py create mode 100644 tests/helpers/__init__.py create mode 100644 tests/helpers/protocol.py create mode 100644 tests/runtime/helper/test_marketplace.py create mode 100644 tests/runtime/helper/test_pkgmgr.py create mode 100644 tests/runtime/io/handlers/test_control_handler.py create mode 100644 tests/runtime/io/handlers/test_import_contracts.py create mode 100644 tests/runtime/io/handlers/test_plugin_handler.py create mode 100644 tests/runtime/io/test_connections.py create mode 100644 tests/runtime/io/test_controllers.py create mode 100644 tests/runtime/io/test_handler.py create mode 100644 tests/runtime/plugin/test_container.py create mode 100644 tests/runtime/plugin/test_manager.py create mode 100644 tests/runtime/test_app.py create mode 100644 tests/utils/test_discovery.py create mode 100644 tests/utils/test_importutil.py create mode 100644 tests/utils/test_platform.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..56055212 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + push: + branches: + - main + - test-build + pull_request: + +permissions: + contents: read + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Set up Python + run: uv python install ${{ matrix.python-version }} + - name: Run tests with coverage + run: | + uv run --python ${{ matrix.python-version }} pytest \ + --cov=langbot_plugin \ + --cov-report=term-missing \ + --cov-fail-under=75 + + test-lint: + name: Test Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Ruff tests + run: uv run ruff check tests --output-format=concise diff --git a/pyproject.toml b/pyproject.toml index e6b51a05..e4c4aa30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,8 @@ dependencies = [ [dependency-groups] dev = [ "mypy>=1.16.0", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.0.0", "ruff>=0.11.12", ] @@ -44,3 +46,17 @@ Issues = "https://github.com/langbot-app/langbot-plugin-sdk/issues" [tool.setuptools] package-data = { "langbot_plugin" = ["assets/templates/*", "assets/*.js"] } + +[tool.pytest.ini_options] +addopts = "-ra" +testpaths = ["tests"] +asyncio_mode = "auto" +pythonpath = ["."] + +[tool.coverage.run] +branch = true +source = ["langbot_plugin"] + +[tool.coverage.report] +show_missing = true +skip_covered = true diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/api/definition/components/test_components.py b/tests/api/definition/components/test_components.py new file mode 100644 index 00000000..488a0793 --- /dev/null +++ b/tests/api/definition/components/test_components.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import pytest + +from langbot_plugin.api.definition.components.base import BaseComponent, NoneComponent +from langbot_plugin.api.definition.components.command.command import Command +from langbot_plugin.api.definition.components.common.event_listener import EventListener +from langbot_plugin.api.definition.components.knowledge_engine.engine import ( + KnowledgeEngine, + KnowledgeEngineCapability, +) +from langbot_plugin.api.definition.components.page import Page, PageRequest, PageResponse +from langbot_plugin.api.definition.components.parser.parser import Parser +from langbot_plugin.api.definition.components.tool.tool import Tool +from langbot_plugin.api.entities.builtin.command.context import CommandReturn +from langbot_plugin.api.entities.events import PersonMessageReceived + + +def test_base_and_none_components_initialize_as_noop(): + component = NoneComponent() + assert isinstance(component, BaseComponent) + + +def test_event_listener_registers_multiple_handlers_for_event_type(): + listener = EventListener() + + async def first(_ctx): + return None + + async def second(_ctx): + return None + + assert listener.handler(PersonMessageReceived)(first) is first + listener.handler(PersonMessageReceived)(second) + + assert listener.registered_handlers[PersonMessageReceived] == [first, second] + + +def test_command_subcommand_decorator_records_metadata(): + command = Command() + + async def handler(_ctx): + yield CommandReturn(text="ok") + + assert command.subcommand("run", help="Run", usage="/run", aliases=["r"])( + handler + ) is handler + + registered = command.registered_subcommands["run"] + assert registered.help == "Run" + assert registered.usage == "/run" + assert registered.aliases == ["r"] + + +def test_command_subcommand_default_aliases_should_not_be_shared(): + first = Command() + second = Command() + + async def first_handler(_ctx): + yield CommandReturn(text="first") + + async def second_handler(_ctx): + yield CommandReturn(text="second") + + first.subcommand("first")(first_handler) + second.subcommand("second")(second_handler) + first.registered_subcommands["first"].aliases.append("alias") + + assert second.registered_subcommands["second"].aliases == [] + + +def test_page_request_response_helpers_and_default_handler(): + request = PageRequest(endpoint="/entries", method="GET", headers={"x": "1"}) + assert request.body is None + assert request.headers == {"x": "1"} + assert PageResponse.ok({"ok": True}).data == {"ok": True} + assert PageResponse.fail("nope").error == "nope" + + +@pytest.mark.asyncio +async def test_page_default_handle_api_returns_not_implemented_failure(): + response = await Page().handle_api(PageRequest(endpoint="/", method="GET")) + + assert response.error == "Not implemented" + + +def test_knowledge_engine_default_capabilities(): + assert KnowledgeEngine.get_capabilities() == [KnowledgeEngineCapability.DOC_INGESTION] + + +def test_abstract_component_kinds_are_stable(): + assert Command.__kind__ == "Command" + assert EventListener.__kind__ == "EventListener" + assert KnowledgeEngine.__kind__ == "KnowledgeEngine" + assert Parser.__kind__ == "Parser" + assert Tool.__kind__ == "Tool" + assert Page.__kind__ == "Page" diff --git a/tests/api/definition/test_manifest.py b/tests/api/definition/test_manifest.py new file mode 100644 index 00000000..bbcef42d --- /dev/null +++ b/tests/api/definition/test_manifest.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import os +import sys +import textwrap + +import pytest + +from langbot_plugin.api.definition.components.manifest import ( + ComponentManifest, + I18nString, + Metadata, + PythonExecution, +) + + +def _manifest(kind: str = "Tool") -> dict: + return { + "apiVersion": "v1", + "kind": kind, + "metadata": { + "name": "weather", + "label": {"en_US": "Weather", "zh_Hans": "天气"}, + "author": "tester", + "version": "1.0.0", + }, + "spec": {"description": "lookup weather"}, + "execution": {"python": {"path": "./weather.py", "attr": "WeatherTool"}}, + } + + +def test_i18n_string_to_dict_omits_missing_locales(): + text = I18nString(en_US="Hello", zh_Hans="你好") + assert text.to_dict() == {"en_US": "Hello", "zh_Hans": "你好"} + + +def test_metadata_fills_optional_description_and_icon_defaults(): + metadata = Metadata(name="demo", label={"en_US": "Demo"}) + assert metadata.description is not None + assert metadata.description.to_dict() == {"en_US": ""} + assert metadata.icon == "" + + +def test_python_execution_strips_current_directory_prefix(): + execution = PythonExecution(path="./components/weather.py", attr="Weather") + assert execution.path == "components/weather.py" + + +def test_component_manifest_properties_and_plain_dict(): + component = ComponentManifest( + owner="plugin", + manifest=_manifest(), + rel_path="components/weather.yaml", + ) + + assert component.kind == "Tool" + assert component.metadata.name == "weather" + assert component.spec == {"description": "lookup weather"} + assert component.icon_rel_path is None + assert component.to_plain_dict() == { + "name": "weather", + "label": {"en_US": "Weather", "zh_Hans": "天气"}, + "description": {"en_US": ""}, + "icon": "", + "spec": {"description": "lookup weather"}, + } + + +def test_component_manifest_icon_path_is_relative_to_manifest_directory(): + manifest = _manifest() + manifest["metadata"]["icon"] = "assets/icon.svg" + component = ComponentManifest( + owner="plugin", + manifest=manifest, + rel_path="components/weather.yaml", + ) + + assert component.icon_rel_path == os.path.join("components", "assets/icon.svg") + + +def test_component_manifest_detection_requires_core_fields(): + assert ComponentManifest.is_component_manifest(_manifest()) is True + assert ComponentManifest.is_component_manifest({"kind": "Tool"}) is False + + +def test_component_manifest_imports_python_component_class(tmp_path, monkeypatch): + component_dir = tmp_path / "components" + component_dir.mkdir() + (component_dir / "__init__.py").write_text("", encoding="utf-8") + (component_dir / "weather.py").write_text( + textwrap.dedent( + """ + class WeatherTool: + pass + """ + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + component = ComponentManifest( + owner="plugin", + manifest=_manifest(), + rel_path="components/weather.yaml", + ) + try: + component_cls = component.get_python_component_class() + assert component_cls.__name__ == "WeatherTool" + finally: + while str(tmp_path) in sys.path: + sys.path.remove(str(tmp_path)) + + +def test_component_manifest_without_execution_cannot_resolve_class(): + manifest = _manifest() + del manifest["execution"] + component = ComponentManifest( + owner="plugin", + manifest=manifest, + rel_path="components/weather.yaml", + ) + + with pytest.raises(ValueError, match="Execution is not set"): + component.get_python_component_class() diff --git a/tests/api/entities/builtin/test_command_context.py b/tests/api/entities/builtin/test_command_context.py new file mode 100644 index 00000000..13c86e2b --- /dev/null +++ b/tests/api/entities/builtin/test_command_context.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import pytest + +from langbot_plugin.api.entities.builtin.command.context import ( + CommandReturn, + ExecuteContext, +) +from langbot_plugin.api.entities.builtin.command.errors import ( + CommandError, + CommandNotFoundError, + CommandOperationError, + CommandPrivilegeError, + ParamNotEnoughError, +) +from langbot_plugin.api.entities.builtin.provider.session import ( + LauncherTypes, + Session, +) + + +def _session() -> Session: + return Session( + launcher_type=LauncherTypes.PERSON, + launcher_id="launcher", + sender_id="sender", + ) + + +@pytest.mark.xfail( + strict=True, + reason="#59 CommandReturn error serializer is not applied by dumps", +) +def test_command_return_serializes_command_error_to_message(): + ret = CommandReturn(error=CommandError(message="failed")) + + assert ret.error.message == "failed" + assert ret.model_dump(mode="json", by_alias=True)["error"] == "failed" + + +def test_execute_context_shift_advances_current_command_and_params(): + context = ExecuteContext( + query_id=1, + session=_session(), + command_text="plugin on demo", + full_command_text="/plugin on demo", + command="plugin", + crt_command="plugin", + params=["on", "demo"], + crt_params=["on", "demo"], + privilege=0, + ) + + assert context.shift() is context + assert context.crt_command == "on" + assert context.crt_params == ["demo"] + + context.shift() + assert context.crt_command == "demo" + assert context.crt_params == [] + + context.shift() + assert context.crt_command == "" + assert context.crt_params == [] + + +@pytest.mark.xfail( + strict=True, + reason="#59 CommandNotFoundError defaults message to None but concatenates it", +) +def test_command_not_found_error_default_message_should_be_constructible(): + assert str(CommandNotFoundError()) == "未知命令: " + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (CommandNotFoundError("demo"), "未知命令: demo"), + (CommandPrivilegeError("demo"), "权限不足: demo"), + (ParamNotEnoughError("demo"), "参数不足: demo"), + (CommandOperationError("demo"), "操作失败: demo"), + ], +) +def test_command_errors_prefix_user_visible_message(error, expected): + assert str(error) == expected diff --git a/tests/api/entities/builtin/test_platform_logger.py b/tests/api/entities/builtin/test_platform_logger.py new file mode 100644 index 00000000..d0af7d04 --- /dev/null +++ b/tests/api/entities/builtin/test_platform_logger.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from langbot_plugin.api.entities.builtin.platform.logger import EventLog, EventLogLevel + + +def test_event_log_to_json_serializes_level_value_and_optional_fields(): + log = EventLog( + seq_id=1, + timestamp=123456, + level=EventLogLevel.WARNING, + text="careful", + images=["img"], + message_session_id="session", + ) + + assert log.to_json() == { + "seq_id": 1, + "timestamp": 123456, + "level": "warning", + "text": "careful", + "images": ["img"], + "message_session_id": "session", + } diff --git a/tests/api/entities/builtin/test_provider_message.py b/tests/api/entities/builtin/test_provider_message.py new file mode 100644 index 00000000..1220ea76 --- /dev/null +++ b/tests/api/entities/builtin/test_provider_message.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import pytest + +from langbot_plugin.api.entities.builtin.platform.message import File, Image, Plain +from langbot_plugin.api.entities.builtin.provider.message import ( + ContentElement, + FunctionCall, + Message, + MessageChunk, + ToolCall, +) + + +def test_content_element_factories_and_string_representations(): + assert str(ContentElement.from_text("hello")) == "hello" + assert str(ContentElement.from_image_url("https://example.com/a.png")) == ( + "[Image](https://example.com/a.png)" + ) + assert str(ContentElement.from_image_base64("abc")) == "[Image](base64)" + assert str(ContentElement.from_file_url("https://example.com/a.txt", "a.txt")) == ( + "[File](https://example.com/a.txt)" + ) + assert str(ContentElement.from_file_base64("abc", "a.txt")) == "[File](a.txt)" + assert str(ContentElement(type="unknown")) == "Unknown content" + + +def test_image_url_content_string_is_truncated(): + long_url = "https://example.com/" + "a" * 200 + element = ContentElement.from_image_url(long_url) + + assert str(element.image_url).endswith("...") + assert len(str(element.image_url)) == 131 + + +def test_message_string_content_converts_to_plain_message_chain_with_prefix(): + message = Message(role="user", content="hello") + chain = message.get_content_platform_message_chain(prefix_text="[p] ") + + assert len(chain) == 1 + assert isinstance(chain[0], Plain) + assert chain[0].text == "[p] hello" + assert message.readable_str() == "user: hello" + + +def test_message_list_content_converts_supported_elements_to_platform_chain(): + message = Message( + role="user", + content=[ + ContentElement.from_image_url("https://example.com/a.png"), + ContentElement.from_file_url("https://example.com/a.txt", "a.txt"), + ContentElement.from_image_base64("YmFzZTY0"), + ContentElement.from_text("hello"), + ], + ) + + chain = message.get_content_platform_message_chain(prefix_text="[p] ") + + assert [type(component) for component in chain] == [Image, File, Image, Plain] + assert chain[-1].text == "[p] hello" + + +def test_message_prefix_is_inserted_when_no_text_component_exists(): + message = Message( + role="user", + content=[ContentElement.from_image_url("https://example.com/a.png")], + ) + + chain = message.get_content_platform_message_chain(prefix_text="[p] ") + + assert isinstance(chain[0], Plain) + assert chain[0].text == "[p] " + assert isinstance(chain[1], Image) + + +def test_message_without_content_returns_none_and_tool_call_readable_string(): + call = ToolCall( + id="call-1", + type="function", + function=FunctionCall(name="search", arguments="{}"), + ) + assert Message(role="assistant").get_content_platform_message_chain() is None + assert Message(role="assistant", tool_calls=[call]).readable_str() == ( + "Call tool: call-1" + ) + assert Message(role="assistant").readable_str() == "Unknown message" + + +def test_message_chunk_matches_message_content_conversion(): + chunk = MessageChunk( + role="assistant", + content=[ContentElement.from_text("partial")], + is_final=False, + msg_sequence=3, + ) + + chain = chunk.get_content_platform_message_chain(prefix_text="[chunk] ") + + assert chain[0].text == "[chunk] partial" + assert chunk.readable_str() == "assistant: partial" + + +@pytest.mark.xfail( + strict=True, + reason="#60 ContentElement allows type='image_url' without image_url payload", +) +def test_message_image_url_content_should_validate_required_payload(): + message = Message(role="user", content=[ContentElement(type="image_url")]) + + with pytest.raises(ValueError): + message.get_content_platform_message_chain() diff --git a/tests/api/entities/builtin/test_rag_models.py b/tests/api/entities/builtin/test_rag_models.py new file mode 100644 index 00000000..0f0e5382 --- /dev/null +++ b/tests/api/entities/builtin/test_rag_models.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from langbot_plugin.api.entities.builtin.provider.message import ContentElement +from langbot_plugin.api.entities.builtin.rag.context import ( + RetrievalContext, + RetrievalResponse, + RetrievalResultEntry, +) +from langbot_plugin.api.entities.builtin.rag.enums import DocumentStatus +from langbot_plugin.api.entities.builtin.rag.errors import ( + CollectionNotFoundError, + EmbeddingError, + FileServiceError, + ParsingError, + VectorStoreError, +) +from langbot_plugin.api.entities.builtin.rag.models import ( + FileMetadata, + FileObject, + IngestionContext, + IngestionResult, + ParseContext, + ParseResult, + TextChunk, + TextSection, +) + + +def test_rag_file_and_parse_models_keep_metadata_isolated(): + first = FileMetadata( + filename="a.txt", + file_size=3, + mime_type="text/plain", + document_id="doc-a", + knowledge_base_id="kb", + ) + second = FileMetadata( + filename="b.txt", + file_size=4, + mime_type="text/plain", + document_id="doc-b", + knowledge_base_id="kb", + ) + first.extra["source"] = "upload" + + assert second.extra == {} + assert FileObject(metadata=first, storage_path="files/a.txt").storage_path == ( + "files/a.txt" + ) + assert ParseContext( + file_content=b"abc", + mime_type="text/plain", + filename="a.txt", + ).metadata == {} + + +def test_rag_text_models_and_parse_result_defaults(): + chunk = TextChunk(text="hello", chunk_id="c1", document_id="doc") + section = TextSection(content="hello", heading="Intro", page=1) + result = ParseResult(text="hello", sections=[section]) + + assert chunk.metadata == {} + assert chunk.embedding is None + assert result.sections[0].heading == "Intro" + assert result.metadata == {} + + +def test_ingestion_context_collection_id_falls_back_to_knowledge_base_id(): + metadata = FileMetadata( + filename="a.txt", + file_size=3, + mime_type="text/plain", + document_id="doc", + knowledge_base_id="kb", + ) + context = IngestionContext( + file_object=FileObject(metadata=metadata, storage_path="files/a.txt"), + knowledge_base_id="kb", + ) + + assert context.get_collection_id() == "kb" + context.collection_id = "collection" + assert context.get_collection_id() == "collection" + + +def test_ingestion_result_serializes_document_status_enum(): + result = IngestionResult( + document_id="doc", + status=DocumentStatus.COMPLETED, + chunks_created=2, + ) + + assert result.model_dump()["status"] is DocumentStatus.COMPLETED + + +def test_retrieval_context_collection_id_fallbacks_and_response_model(): + context = RetrievalContext(query="hello", knowledge_base_id="kb") + assert context.get_collection_id() == "kb" + assert RetrievalContext(query="hello").get_collection_id() == "" + + entry = RetrievalResultEntry( + id="chunk", + content=[ContentElement.from_text("hello")], + metadata={"doc": "a"}, + distance=0.1, + score=0.9, + ) + response = RetrievalResponse(results=[entry], total_found=1) + + assert response.results[0].content[0].text == "hello" + assert response.metadata == {} + + +def test_rag_host_service_errors_preserve_original_error(): + original = RuntimeError("backend unavailable") + + embedding_error = EmbeddingError("embedding failed", original) + vector_error = VectorStoreError("vector failed", original) + file_error = FileServiceError("file failed", original) + + assert str(embedding_error) == "embedding failed" + assert embedding_error.original_error is original + assert vector_error.original_error is original + assert file_error.original_error is original + + +def test_rag_specialized_errors_keep_context_fields(): + collection_error = CollectionNotFoundError("kb-1") + parsing_error = ParsingError("parse failed", file_path="docs/a.txt") + + assert collection_error.collection_id == "kb-1" + assert str(collection_error) == "Collection not found or not accessible: kb-1" + assert parsing_error.file_path == "docs/a.txt" diff --git a/tests/api/entities/test_context.py b/tests/api/entities/test_context.py new file mode 100644 index 00000000..11da7d99 --- /dev/null +++ b/tests/api/entities/test_context.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from langbot_plugin.api.entities import context as context_module +from langbot_plugin.api.definition.abstract.platform.adapter import ( + AbstractMessagePlatformAdapter, +) +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.pipeline.query import Query +from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain +from langbot_plugin.api.entities.builtin.platform.events import FriendMessage +from langbot_plugin.api.entities.builtin.platform.entities import Friend +from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes +from langbot_plugin.api.entities.events import PersonMessageReceived +from langbot_plugin.api.entities.context import EventContext + + +class MockLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +class MockAdapter(AbstractMessagePlatformAdapter): + config: dict = {} + logger: MockLogger + + async def send_message(self, target_type, target_id, message): + pass + + async def reply_message(self, message_source, message, quote_origin=False): + pass + + async def is_muted(self, group_id): + return False + + def register_listener(self, event_type, callback): + pass + + def unregister_listener(self, event_type, callback): + pass + + async def run_async(self): + pass + + async def kill(self) -> bool: + return True + + +def _make_friend_message(chain: MessageChain) -> FriendMessage: + return FriendMessage( + sender=Friend(id="sender", nickname="Tester", remark=""), + message_chain=chain, + ) + + +def _make_query(chain: MessageChain) -> Query: + return Query( + query_id=1, + launcher_type=LauncherTypes.PERSON, + launcher_id="launcher", + sender_id="sender", + message_event=_make_friend_message(chain), + message_chain=chain, + adapter=MockAdapter(bot_account_id="bot", config={}, logger=MockLogger()), + session=None, + ) + + +def _event(): + chain = MessageChain([Plain(text="hello")]) + return PersonMessageReceived( + query=_make_query(chain), + launcher_type="person", + launcher_id="launcher", + sender_id="sender", + message_chain=chain, + message_event=_make_friend_message(chain), + ) + + +def test_event_context_from_event_assigns_monotonic_id_and_caches_context(): + context_module.cached_event_contexts.clear() + context_module.global_eid_index = 0 + + first = EventContext.from_event(_event()) + second = EventContext.from_event(_event()) + + assert first.eid == 0 + assert second.eid == 1 + assert context_module.cached_event_contexts[0] is first + assert context_module.cached_event_contexts[1] is second + assert first.event_name == "PersonMessageReceived" + + +def test_event_context_prevent_flags_are_mutable_runtime_state(): + ctx = EventContext.from_event(_event()) + + assert ctx.is_prevented_default() is False + assert ctx.is_prevented_postorder() is False + + ctx.prevent_default() + ctx.prevent_postorder() + + assert ctx.is_prevented_default() is True + assert ctx.is_prevented_postorder() is True + + +def test_event_context_validates_event_from_serialized_payload(): + event = _event() + payload = event.model_dump() + payload["event_name"] = "PersonMessageReceived" + + ctx = EventContext( + query_id=event.query.query_id, + eid=99, + event_name="PersonMessageReceived", + event=payload, + ) + + assert isinstance(ctx.event, PersonMessageReceived) + assert ctx.event.sender_id == "sender" + + +def test_query_variable_helpers_initialize_and_return_runtime_state(): + query = _make_query(MessageChain([Plain(text="hello")])) + query.variables = None + + assert query.get_variable("missing") is None + assert query.get_variables() == {} + + query.set_variable("answer", 42) + + assert query.get_variable("answer") == 42 + assert query.get_variables() == {"answer": 42} + + +def test_query_model_dump_serializes_public_request_payload(): + query = _make_query(MessageChain([Plain(text="hello")])) + query.bot_uuid = "bot-uuid" + query.pipeline_uuid = "pipeline-uuid" + query.pipeline_config = {"enabled": True} + + payload = query.model_dump() + + assert payload["query_id"] == 1 + assert payload["launcher_type"] == "person" + assert payload["launcher_id"] == "launcher" + assert payload["sender_id"] == "sender" + assert payload["bot_uuid"] == "bot-uuid" + assert payload["pipeline_uuid"] == "pipeline-uuid" + assert payload["pipeline_config"] == {"enabled": True} + assert payload["session"] is None + assert payload["messages"] == [] + assert payload["prompt"] is None + assert payload["message_chain"][0]["text"] == "hello" diff --git a/tests/api/proxies/test_base.py b/tests/api/proxies/test_base.py new file mode 100644 index 00000000..7ad9c4ac --- /dev/null +++ b/tests/api/proxies/test_base.py @@ -0,0 +1,11 @@ +from langbot_plugin.api.proxies.base import APIProxy + + +def test_api_proxy_stores_runtime_handler_and_container(): + runtime_handler = object() + plugin_container = object() + + proxy = APIProxy(runtime_handler, plugin_container) + + assert proxy.plugin_runtime_handler is runtime_handler + assert proxy.plugin_container is plugin_container diff --git a/tests/api/proxies/test_langbot_api.py b/tests/api/proxies/test_langbot_api.py new file mode 100644 index 00000000..b24ad1e2 --- /dev/null +++ b/tests/api/proxies/test_langbot_api.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import base64 + +import pytest + +from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain +from langbot_plugin.api.entities.builtin.provider.message import Message +from langbot_plugin.api.proxies.langbot_api import LangBotAPIProxy +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + +class FakeHandler: + def __init__(self, responses=None): + self.responses = responses or {} + self.calls: list[tuple[PluginToRuntimeAction, dict, float | None]] = [] + self.local_files = {"file-key": b"file-bytes"} + self.deleted_files: list[str] = [] + + async def call_action(self, action, data, timeout=None): + self.calls.append((action, data, timeout)) + response = self.responses.get(action) + if callable(response): + return response(data) + if response is not None: + return response + return {} + + async def read_local_file(self, file_key: str) -> bytes: + return self.local_files[file_key] + + async def delete_local_file(self, file_key: str) -> None: + self.deleted_files.append(file_key) + + +@pytest.mark.asyncio +async def test_langbot_api_basic_read_methods_unwrap_response_fields(): + handler = FakeHandler( + { + PluginToRuntimeAction.GET_LANGBOT_VERSION: {"version": "1.2.3"}, + PluginToRuntimeAction.GET_BOTS: {"bots": ["bot"]}, + PluginToRuntimeAction.GET_BOT_INFO: {"bot": {"uuid": "bot"}}, + PluginToRuntimeAction.GET_LLM_MODELS: {"llm_models": ["model"]}, + } + ) + proxy = LangBotAPIProxy(handler) + + assert await proxy.get_langbot_version() == "1.2.3" + assert await proxy.get_bots() == ["bot"] + assert await proxy.get_bot_info("bot") == {"uuid": "bot"} + assert await proxy.get_llm_models() == ["model"] + assert handler.calls[2] == ( + PluginToRuntimeAction.GET_BOT_INFO, + {"bot_uuid": "bot"}, + None, + ) + + +@pytest.mark.asyncio +async def test_send_message_serializes_message_chain_payload(): + handler = FakeHandler() + proxy = LangBotAPIProxy(handler) + + await proxy.send_message( + "bot", + "person", + "target", + MessageChain([Plain(text="hello")]), + ) + + action, data, timeout = handler.calls[0] + assert action is PluginToRuntimeAction.SEND_MESSAGE + assert data["message_chain"] == [{"type": "Plain", "text": "hello"}] + assert timeout is None + + +@pytest.mark.asyncio +async def test_invoke_llm_serializes_messages_and_uses_effective_timeout(): + handler = FakeHandler( + { + PluginToRuntimeAction.INVOKE_LLM: { + "message": {"role": "assistant", "content": "ok"} + } + } + ) + proxy = LangBotAPIProxy(handler) + + result = await proxy.invoke_llm( + "model", + [Message(role="user", content="hello")], + extra_args={"temperature": 0}, + ) + + assert result == Message(role="assistant", content="ok") + action, data, timeout = handler.calls[0] + assert action is PluginToRuntimeAction.INVOKE_LLM + assert data["timeout"] == 120.0 + assert timeout == 120.0 + + +@pytest.mark.asyncio +async def test_storage_helpers_encode_and_decode_base64(): + handler = FakeHandler( + { + PluginToRuntimeAction.GET_PLUGIN_STORAGE: { + "value_base64": base64.b64encode(b"value").decode() + }, + PluginToRuntimeAction.GET_WORKSPACE_STORAGE: { + "value_base64": base64.b64encode(b"workspace").decode() + }, + PluginToRuntimeAction.GET_CONFIG_FILE: { + "file_base64": base64.b64encode(b"config").decode() + }, + PluginToRuntimeAction.GET_PLUGIN_STORAGE_KEYS: {"keys": ["a"]}, + PluginToRuntimeAction.GET_WORKSPACE_STORAGE_KEYS: {"keys": ["b"]}, + } + ) + proxy = LangBotAPIProxy(handler) + + await proxy.set_plugin_storage("k", b"value") + assert await proxy.get_plugin_storage("k") == b"value" + assert await proxy.get_plugin_storage_keys() == ["a"] + await proxy.delete_plugin_storage("k") + await proxy.set_workspace_storage("w", b"workspace") + assert await proxy.get_workspace_storage("w") == b"workspace" + assert await proxy.get_workspace_storage_keys() == ["b"] + await proxy.delete_workspace_storage("w") + assert await proxy.get_config_file("cfg") == b"config" + + assert handler.calls[0][1] == {"key": "k", "value_base64": "dmFsdWU="} + set_workspace_call = [ + call + for call in handler.calls + if call[0] is PluginToRuntimeAction.SET_WORKSPACE_STORAGE + ][0] + assert set_workspace_call[1] == {"key": "w", "value_base64": "d29ya3NwYWNl"} + + +@pytest.mark.asyncio +async def test_tool_and_rag_helpers_preserve_payload_contracts(): + handler = FakeHandler( + { + PluginToRuntimeAction.LIST_PLUGINS_MANIFEST: {"plugins": ["p"]}, + PluginToRuntimeAction.LIST_COMMANDS: {"commands": ["cmd"]}, + PluginToRuntimeAction.LIST_TOOLS: {"tools": [{"name": "tool"}]}, + PluginToRuntimeAction.GET_TOOL_DETAIL: {"tool": {"name": "tool"}}, + PluginToRuntimeAction.CALL_TOOL: {"tool_response": {"ok": True}}, + PluginToRuntimeAction.INVOKE_EMBEDDING: {"vectors": [[0.1]]}, + PluginToRuntimeAction.VECTOR_SEARCH: {"results": [{"id": "1"}]}, + PluginToRuntimeAction.VECTOR_DELETE: {"count": 2}, + PluginToRuntimeAction.VECTOR_LIST: {"items": [], "total": 0}, + PluginToRuntimeAction.LIST_KNOWLEDGE_BASES: {"knowledge_bases": []}, + PluginToRuntimeAction.RETRIEVE_KNOWLEDGE: {"results": []}, + } + ) + proxy = LangBotAPIProxy(handler) + + assert await proxy.list_plugins_manifest() == ["p"] + assert await proxy.list_commands() == ["cmd"] + assert await proxy.list_tools() == [{"name": "tool"}] + assert await proxy.get_tool_detail("tool") == {"name": "tool"} + assert await proxy.call_tool("tool", {"q": 1}, {"s": 1}, 7) == {"ok": True} + assert await proxy.invoke_embedding("embed", ["hi"]) == [[0.1]] + await proxy.vector_upsert("c", [[0.1]], ["id"], documents=["doc"]) + assert await proxy.vector_search("c", [0.1], filters={"a": 1}) == [{"id": "1"}] + assert await proxy.vector_delete("c", file_ids=["f"]) == 2 + assert await proxy.vector_list("c") == {"items": [], "total": 0} + assert await proxy.list_knowledge_bases() == [] + assert await proxy.retrieve_knowledge("kb", "query") == [] + + call_tool_call = [ + call for call in handler.calls if call[0] is PluginToRuntimeAction.CALL_TOOL + ][0] + assert call_tool_call[1]["tool_parameters"] == {"q": 1} + assert call_tool_call[2] == 180 + + +@pytest.mark.asyncio +async def test_get_knowledge_file_stream_reads_and_deletes_local_chunk_file(): + handler = FakeHandler( + {PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM: {"file_key": "file-key"}} + ) + proxy = LangBotAPIProxy(handler) + + assert await proxy.get_knowledge_file_stream("storage/path") == b"file-bytes" + assert handler.deleted_files == ["file-key"] + + +@pytest.mark.asyncio +async def test_parser_helpers_preserve_payload_contracts(): + handler = FakeHandler( + { + PluginToRuntimeAction.LIST_PARSERS: {"parsers": [{"name": "parser"}]}, + PluginToRuntimeAction.INVOKE_PARSER: {"text": "parsed"}, + } + ) + proxy = LangBotAPIProxy(handler) + + assert await proxy.list_parsers("text/plain") == [{"name": "parser"}] + assert await proxy.invoke_parser( + "author", + "parser", + "files/a.txt", + "text/plain", + "a.txt", + ) == {"text": "parsed"} + assert handler.calls[-1] == ( + PluginToRuntimeAction.INVOKE_PARSER, + { + "plugin_author": "author", + "plugin_name": "parser", + "storage_path": "files/a.txt", + "mime_type": "text/plain", + "filename": "a.txt", + "metadata": {}, + }, + 300, + ) diff --git a/tests/api/proxies/test_query_based_api.py b/tests/api/proxies/test_query_based_api.py new file mode 100644 index 00000000..96f3c2ca --- /dev/null +++ b/tests/api/proxies/test_query_based_api.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import pytest + +from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain +from langbot_plugin.api.proxies.query_based_api import QueryBasedAPIProxy +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + +class FakeHandler: + def __init__(self, responses=None): + self.responses = responses or {} + self.calls = [] + + async def call_action(self, action, data, timeout=None): + self.calls.append((action, data, timeout)) + return self.responses.get(action, {}) + + +@pytest.mark.asyncio +async def test_query_based_proxy_reply_serializes_query_and_message_chain(): + handler = FakeHandler() + proxy = QueryBasedAPIProxy.model_construct( + query_id=123, plugin_runtime_handler=handler + ) + + await proxy.reply(MessageChain([Plain(text="hi")]), quote_origin=True) + + assert handler.calls == [ + ( + PluginToRuntimeAction.REPLY_MESSAGE, + { + "query_id": 123, + "message_chain": [{"type": "Plain", "text": "hi"}], + "quote_origin": True, + }, + 180, + ) + ] + assert proxy.model_dump() == {"query_id": 123} + + +@pytest.mark.asyncio +async def test_query_based_proxy_query_var_helpers(): + handler = FakeHandler( + { + PluginToRuntimeAction.GET_BOT_UUID: {"bot_uuid": "bot"}, + PluginToRuntimeAction.GET_QUERY_VAR: {"value": "v"}, + PluginToRuntimeAction.GET_QUERY_VARS: {"vars": {"k": "v"}}, + PluginToRuntimeAction.CREATE_NEW_CONVERSATION: {"uuid": "conv"}, + } + ) + proxy = QueryBasedAPIProxy.model_construct(query_id=7, plugin_runtime_handler=handler) + + assert await proxy.get_bot_uuid() == "bot" + await proxy.set_query_var("k", "v") + assert await proxy.get_query_var("k") == "v" + assert await proxy.get_query_vars() == {"k": "v"} + assert await proxy.create_new_conversation() == {"uuid": "conv"} + + assert handler.calls[1] == ( + PluginToRuntimeAction.SET_QUERY_VAR, + {"query_id": 7, "key": "k", "value": "v"}, + None, + ) + + +@pytest.mark.asyncio +async def test_query_based_proxy_pipeline_knowledge_helpers(): + handler = FakeHandler( + { + PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES: { + "knowledge_bases": [{"uuid": "kb"}] + }, + PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE: { + "results": [{"content": "hit"}] + }, + } + ) + proxy = QueryBasedAPIProxy.model_construct(query_id=7, plugin_runtime_handler=handler) + + assert await proxy.list_pipeline_knowledge_bases() == [{"uuid": "kb"}] + assert await proxy.retrieve_knowledge("kb", "query", top_k=3) == [ + {"content": "hit"} + ] + assert handler.calls[-1] == ( + PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE, + { + "query_id": 7, + "kb_id": "kb", + "query_text": "query", + "top_k": 3, + "filters": {}, + }, + None, + ) diff --git a/tests/cli/run/test_controller.py b/tests/cli/run/test_controller.py new file mode 100644 index 00000000..d7a51d90 --- /dev/null +++ b/tests/cli/run/test_controller.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from langbot_plugin.api.definition.components.base import NoneComponent +from langbot_plugin.api.definition.components.common.event_listener import EventListener +from langbot_plugin.api.definition.components.manifest import ComponentManifest +from langbot_plugin.api.definition.components.tool.tool import Tool +from langbot_plugin.api.definition.plugin import BasePlugin, NonePlugin +from langbot_plugin.api.entities.builtin.provider import session as provider_session +from langbot_plugin.cli.run.controller import PluginRuntimeController +from langbot_plugin.runtime.plugin.container import RuntimeContainerStatus + + +class DemoPlugin(BasePlugin): + initialized = False + + async def initialize(self) -> None: + self.initialized = True + + +class DemoTool(Tool): + initialized = False + + async def initialize(self) -> None: + self.initialized = True + + async def call( + self, + params: dict[str, Any], + session: provider_session.Session, + query_id: int, + ) -> str: + return "ok" + + +class DemoEventListener(EventListener): + initialized = False + + async def initialize(self) -> None: + self.initialized = True + + +def _manifest(kind: str, name: str) -> ComponentManifest: + return ComponentManifest( + owner="tester", + rel_path=f"{name}.yaml", + manifest={ + "apiVersion": "v1", + "kind": kind, + "metadata": { + "name": name, + "label": {"en_US": name.title()}, + "author": "tester", + "version": "1.0.0", + }, + "spec": {}, + "execution": {"python": {"path": f"./{name}.py", "attr": name.title()}}, + }, + ) + + +def _controller() -> PluginRuntimeController: + return PluginRuntimeController( + plugin_manifest=_manifest("Plugin", "demo"), + component_manifests=[ + _manifest("Tool", "lookup"), + _manifest("EventListener", "events"), + _manifest("UnknownKind", "unknown"), + ], + stdio=True, + ws_debug_url="ws://runtime/plugin/ws", + ) + + +def test_controller_builds_unmounted_placeholder_container(): + controller = _controller() + + assert controller._stdio is True + assert controller.ws_debug_url == "ws://runtime/plugin/ws" + assert controller.plugin_container.status is RuntimeContainerStatus.UNMOUNTED + assert isinstance(controller.plugin_container.plugin_instance, NonePlugin) + assert [component.manifest.kind for component in controller.plugin_container.components] == [ + "Tool", + "EventListener", + "UnknownKind", + ] + assert all( + isinstance(component.component_instance, NoneComponent) + for component in controller.plugin_container.components + ) + + +@pytest.mark.asyncio +async def test_initialize_creates_plugin_and_supported_component_instances(monkeypatch): + controller = _controller() + controller.handler = object() + component_classes = { + "Plugin": DemoPlugin, + "Tool": DemoTool, + "EventListener": DemoEventListener, + } + + def fake_component_class(self: ComponentManifest): + return component_classes[self.kind] + + monkeypatch.setattr( + ComponentManifest, + "get_python_component_class", + fake_component_class, + ) + + await controller.initialize( + { + "enabled": False, + "priority": 42, + "plugin_config": {"token": "secret"}, + } + ) + + plugin = controller.plugin_container.plugin_instance + assert isinstance(plugin, DemoPlugin) + assert plugin.initialized is True + assert plugin.config == {"token": "secret"} + assert plugin.plugin_runtime_handler is controller.handler + assert controller.plugin_container.enabled is False + assert controller.plugin_container.priority == 42 + assert controller.plugin_container.status is RuntimeContainerStatus.INITIALIZED + + tool, event_listener, unknown = controller.plugin_container.components + assert isinstance(tool.component_instance, DemoTool) + assert tool.component_instance.initialized is True + assert tool.component_instance.plugin is plugin + assert isinstance(event_listener.component_instance, DemoEventListener) + assert event_listener.component_instance.initialized is True + assert event_listener.component_instance.plugin is plugin + assert isinstance(unknown.component_instance, NoneComponent) + + +@pytest.mark.asyncio +async def test_cleanup_instances_resets_runtime_objects(monkeypatch): + controller = _controller() + controller.handler = object() + + component_classes = { + "Plugin": DemoPlugin, + "Tool": DemoTool, + "EventListener": DemoEventListener, + } + + def fake_component_class(self: ComponentManifest): + return component_classes[self.kind] + + monkeypatch.setattr( + ComponentManifest, + "get_python_component_class", + fake_component_class, + ) + + await controller.initialize( + {"enabled": True, "priority": 0, "plugin_config": {}} + ) + await controller.cleanup_instances() + + assert isinstance(controller.plugin_container.plugin_instance, NonePlugin) + assert controller.plugin_container.status is RuntimeContainerStatus.UNMOUNTED + assert all( + isinstance(component.component_instance, NoneComponent) + for component in controller.plugin_container.components + ) diff --git a/tests/cli/run/test_runtime_handler.py b/tests/cli/run/test_runtime_handler.py new file mode 100644 index 00000000..353b7d09 --- /dev/null +++ b/tests/cli/run/test_runtime_handler.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from langbot_plugin.api.definition.components.base import NoneComponent +from langbot_plugin.api.definition.components.page import Page, PageRequest +from langbot_plugin.api.definition.components.tool.tool import Tool +from langbot_plugin.cli.run.handler import PluginRuntimeHandler, _resolve_asset_path +from langbot_plugin.entities.io.actions.enums import RuntimeToPluginAction + +from tests.helpers.protocol import ProtocolConnection, ProtocolSession + + +class FakeManifest: + def __init__(self, kind: str = "Plugin", name: str = "demo"): + self.kind = kind + self.metadata = SimpleNamespace(name=name) + self.icon_rel_path = None + + def model_dump(self, **kwargs): + return { + "kind": self.kind, + "metadata": {"name": self.metadata.name}, + } + + +class FakePluginContainer: + def __init__(self): + self.manifest = FakeManifest() + self.components = [] + + def model_dump(self, **kwargs): + return { + "manifest": self.manifest.model_dump(), + "components": [ + component.manifest.model_dump() for component in self.components + ], + } + + +class FakeComponentContainer: + def __init__(self, kind: str, name: str, component_instance): + self.manifest = FakeManifest(kind=kind, name=name) + self.component_instance = component_instance + + +class FakePage(Page): + def __init__(self): + self.requests = [] + + async def handle_api(self, request: PageRequest): + self.requests.append(request) + return {"endpoint": request.endpoint, "body": request.body} + + +class FakeTool(Tool): + def __init__(self): + self.calls = [] + + async def call(self, params, session, query_id): + self.calls.append((params, session, query_id)) + return {"ok": True, "sender_id": session.sender_id, "query_id": query_id} + + +def _handler(): + initialized = [] + + async def initialize(plugin_settings): + initialized.append(plugin_settings) + + handler = PluginRuntimeHandler(ProtocolConnection(), initialize) + handler.plugin_container = FakePluginContainer() + return handler, initialized + + +def test_resolve_asset_path_accepts_assets_and_component_page_files(tmp_path, monkeypatch): + assets_file = tmp_path / "assets" / "icon.svg" + page_file = tmp_path / "components" / "pages" / "settings.html" + outside_file = tmp_path / "components" / "tools" / "secret.txt" + assets_file.parent.mkdir() + page_file.parent.mkdir(parents=True) + outside_file.parent.mkdir(parents=True) + assets_file.write_text("icon", encoding="utf-8") + page_file.write_text("page", encoding="utf-8") + outside_file.write_text("secret", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + assert _resolve_asset_path("icon.svg") == assets_file.resolve() + assert _resolve_asset_path("components/pages/settings.html") == page_file.resolve() + assert _resolve_asset_path("components/tools/secret.txt") is None + assert _resolve_asset_path(str(assets_file.resolve())) is None + + +async def test_plugin_runtime_handler_initializes_plugin_and_returns_container(): + handler, initialized = _handler() + + async with ProtocolSession(handler) as session: + initialized_response = await session.request( + RuntimeToPluginAction.INITIALIZE_PLUGIN.value, + {"plugin_settings": {"enabled": True}}, + seq_id=1, + ) + container_response = await session.request( + RuntimeToPluginAction.GET_PLUGIN_CONTAINER.value, + seq_id=2, + ) + + assert initialized_response["code"] == 0 + assert initialized == [{"enabled": True}] + assert container_response["data"] == { + "manifest": {"kind": "Plugin", "metadata": {"name": "demo"}}, + "components": [], + } + + +async def test_plugin_runtime_handler_icon_without_icon_path_returns_empty_payload(): + handler, _initialized = _handler() + + async with ProtocolSession(handler) as session: + response = await session.request(RuntimeToPluginAction.GET_PLUGIN_ICON.value) + + assert response["data"] == {"plugin_icon_file_key": "", "mime_type": ""} + + +async def test_plugin_runtime_handler_sends_readme_file_key(tmp_path, monkeypatch): + handler, _initialized = _handler() + (tmp_path / "readme").mkdir() + (tmp_path / "readme" / "README_zh.md").write_bytes(b"zh readme") + monkeypatch.chdir(tmp_path) + sent = [] + + async def fake_send_file(file_bytes, extension): + sent.append((file_bytes, extension)) + return "readme-key" + + monkeypatch.setattr(handler, "send_file", fake_send_file) + + async with ProtocolSession(handler) as session: + response = await session.request( + RuntimeToPluginAction.GET_PLUGIN_README.value, + {"language": "zh"}, + ) + + assert sent == [(b"zh readme", "md")] + assert response["data"] == { + "plugin_readme_file_key": "readme-key", + "mime_type": "text/markdown", + } + + +async def test_plugin_runtime_handler_get_assets_file_rejects_path_traversal( + tmp_path, + monkeypatch, +): + handler, _initialized = _handler() + outside = tmp_path / "secret.txt" + outside.write_text("secret", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + async with ProtocolSession(handler) as session: + response = await session.request( + RuntimeToPluginAction.GET_PLUGIN_ASSETS_FILE.value, + {"file_key": "../secret.txt"}, + ) + + assert response["data"] == {"file_file_key": None, "mime_type": None} + + +async def test_plugin_runtime_handler_get_assets_file_sends_allowed_asset( + tmp_path, + monkeypatch, +): + handler, _initialized = _handler() + asset = tmp_path / "assets" / "config.json" + asset.parent.mkdir() + asset.write_bytes(b'{"ok": true}') + monkeypatch.chdir(tmp_path) + sent = [] + + async def fake_send_file(file_bytes, extension): + sent.append((file_bytes, extension)) + return "asset-key" + + monkeypatch.setattr(handler, "send_file", fake_send_file) + + async with ProtocolSession(handler) as session: + response = await session.request( + RuntimeToPluginAction.GET_PLUGIN_ASSETS_FILE.value, + {"file_key": "config.json"}, + ) + + assert sent == [(b'{"ok": true}', "")] + assert response["data"] == { + "file_file_key": "asset-key", + "mime_type": "application/json", + } + + +async def test_plugin_runtime_handler_page_api_reports_missing_and_uninitialized_page(): + handler, _initialized = _handler() + handler.plugin_container.components = [ + FakeComponentContainer(Page.__kind__, "settings", NoneComponent()) + ] + + async with ProtocolSession(handler) as session: + missing_page_id = await session.request( + RuntimeToPluginAction.PAGE_API.value, + {}, + seq_id=1, + ) + uninitialized = await session.request( + RuntimeToPluginAction.PAGE_API.value, + {"page_id": "settings"}, + seq_id=2, + ) + missing = await session.request( + RuntimeToPluginAction.PAGE_API.value, + {"page_id": "missing"}, + seq_id=3, + ) + + assert missing_page_id["data"] == { + "data": None, + "error": "page_id is required", + } + assert uninitialized["data"] == { + "data": None, + "error": "Page component is not initialized", + } + assert missing["data"] == {"data": None, "error": "Page 'missing' not found"} + + +async def test_plugin_runtime_handler_page_api_invokes_page_component(): + handler, _initialized = _handler() + page = FakePage() + handler.plugin_container.components = [ + FakeComponentContainer(Page.__kind__, "settings", page) + ] + + async with ProtocolSession(handler) as session: + response = await session.request( + RuntimeToPluginAction.PAGE_API.value, + { + "page_id": "settings", + "endpoint": "/save", + "method": "PUT", + "body": {"enabled": True}, + }, + ) + + assert response["data"] == { + "data": {"endpoint": "/save", "body": {"enabled": True}}, + "error": None, + } + assert page.requests[0].method == "PUT" + + +async def test_plugin_runtime_handler_call_tool_invokes_matching_tool_component(): + handler, _initialized = _handler() + tool = FakeTool() + handler.plugin_container.components = [ + FakeComponentContainer(Tool.__kind__, "weather", tool) + ] + + async with ProtocolSession(handler) as session: + response = await session.request( + RuntimeToPluginAction.CALL_TOOL.value, + { + "tool_name": "weather", + "tool_parameters": {"city": "Shanghai"}, + "session": { + "launcher_type": "person", + "launcher_id": "launcher", + "sender_id": "sender", + }, + "query_id": 7, + }, + ) + + assert response["data"] == { + "tool_response": {"ok": True, "sender_id": "sender", "query_id": 7} + } + assert tool.calls[0][0] == {"city": "Shanghai"} + + +async def test_plugin_runtime_handler_call_tool_reports_missing_or_uninitialized_tool(): + handler, _initialized = _handler() + handler.plugin_container.components = [ + FakeComponentContainer(Tool.__kind__, "weather", NoneComponent()) + ] + + async with ProtocolSession(handler) as session: + uninitialized = await session.request( + RuntimeToPluginAction.CALL_TOOL.value, + { + "tool_name": "weather", + "tool_parameters": {}, + "session": { + "launcher_type": "person", + "launcher_id": "launcher", + "sender_id": "sender", + }, + "query_id": 1, + }, + seq_id=1, + ) + missing = await session.request( + RuntimeToPluginAction.CALL_TOOL.value, + { + "tool_name": "missing", + "tool_parameters": {}, + "session": {}, + "query_id": 1, + }, + seq_id=2, + ) + + assert uninitialized["code"] == 1 + assert uninitialized["message"] == "Tool is not initialized" + assert missing["code"] == 1 + assert missing["message"] == "Tool missing not found" + + +async def test_plugin_runtime_handler_shutdown_schedules_callback(): + handler, _initialized = _handler() + called = asyncio.Event() + + async def shutdown(): + called.set() + + handler.shutdown_callback = shutdown + + async with ProtocolSession(handler) as session: + response = await session.request(RuntimeToPluginAction.SHUTDOWN.value) + await asyncio.wait_for(called.wait(), timeout=1) + + assert response["data"] == {} diff --git a/tests/cli/test_buildplugin.py b/tests/cli/test_buildplugin.py new file mode 100644 index 00000000..6f1cfe50 --- /dev/null +++ b/tests/cli/test_buildplugin.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import os +import zipfile + +import yaml + +from langbot_plugin.cli.commands import buildplugin + + +def test_parse_gitignore_ignores_comments_and_blank_lines(tmp_path): + gitignore = tmp_path / ".gitignore" + gitignore.write_text( + "\n# cache\n.env\nbuild/\n*.pyc\n", + encoding="utf-8", + ) + + assert buildplugin.parse_gitignore(str(gitignore)) == [".env", "build/", "*.pyc"] + + +def test_should_ignore_supports_directory_root_wildcard_and_exact_patterns(): + patterns = ["build/", "/dist", "*.pyc", "secret.txt"] + + assert buildplugin.should_ignore("build/output.txt", patterns) is True + assert buildplugin.should_ignore("dist/plugin.zip", patterns) is True + assert buildplugin.should_ignore("pkg/module.pyc", patterns) is True + assert buildplugin.should_ignore("nested/secret.txt", patterns) is True + assert buildplugin.should_ignore("src/main.py", patterns) is False + + +def test_build_plugin_process_packages_manifest_and_filters_ignored_files( + tmp_path, monkeypatch +): + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + output_dir = tmp_path / "dist" + (plugin_dir / "manifest.yaml").write_text( + yaml.safe_dump( + { + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "name": "demo", + "label": {"en_US": "Demo"}, + "author": "tester", + "version": "0.1.0", + }, + "spec": {"components": {}}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + (plugin_dir / "main.py").write_text("print('hello')\n", encoding="utf-8") + (plugin_dir / ".env").write_text("SECRET=1\n", encoding="utf-8") + (plugin_dir / ".gitignore").write_text("ignored.txt\ncache/\n", encoding="utf-8") + (plugin_dir / "ignored.txt").write_text("ignore", encoding="utf-8") + cache_dir = plugin_dir / "cache" + cache_dir.mkdir() + (cache_dir / "data.txt").write_text("ignore", encoding="utf-8") + monkeypatch.chdir(plugin_dir) + monkeypatch.setattr(buildplugin, "cli_print", lambda *args, **kwargs: None) + + package_path = buildplugin.build_plugin_process(str(output_dir)) + + assert package_path == os.path.join( + str(output_dir), "tester-demo-0.1.0.lbpkg" + ) + with zipfile.ZipFile(package_path) as package: + names = set(package.namelist()) + assert {"manifest.yaml", "main.py", ".gitignore"} <= names + assert ".env" not in names + assert "ignored.txt" not in names + assert "cache/data.txt" not in names + + +def test_build_plugin_process_returns_none_when_manifest_missing(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(buildplugin, "cli_print", lambda *args, **kwargs: None) + + assert buildplugin.build_plugin_process(str(tmp_path / "dist")) is None diff --git a/tests/cli/test_gencomponent.py b/tests/cli/test_gencomponent.py new file mode 100644 index 00000000..2bfb42d2 --- /dev/null +++ b/tests/cli/test_gencomponent.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import yaml + +from langbot_plugin.cli.commands import gencomponent + + +def _write_plugin_manifest(path): + (path / "manifest.yaml").write_text( + yaml.safe_dump( + { + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "author": "tester", + "name": "demo", + "label": {"en_US": "Demo"}, + "version": "0.1.0", + }, + "spec": {"components": {}}, + "execution": {"python": {"path": "main.py", "attr": "Demo"}}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + +def test_generate_component_requires_plugin_root(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + + gencomponent.generate_component_process("Command") + + assert "!!" in capsys.readouterr().out + + +def test_generate_component_reports_unknown_component(tmp_path, monkeypatch, capsys): + _write_plugin_manifest(tmp_path) + monkeypatch.chdir(tmp_path) + + gencomponent.generate_component_process("Nope") + + output = capsys.readouterr().out + assert "!!" in output + assert "Command" in output + + +def test_generate_command_component_creates_files_and_updates_manifest( + tmp_path, monkeypatch +): + _write_plugin_manifest(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(gencomponent, "cli_print", lambda *args: None) + monkeypatch.setattr( + gencomponent, + "input_form_values", + lambda fields: { + "cmd_name": "hello", + "cmd_description": "Say hello", + }, + ) + + gencomponent.generate_component_process("Command") + + assert (tmp_path / "components" / "__init__.py").is_file() + assert (tmp_path / "components" / "commands" / "__init__.py").is_file() + assert (tmp_path / "components" / "commands" / "hello.yaml").is_file() + assert (tmp_path / "components" / "commands" / "hello.py").is_file() + manifest = yaml.safe_load((tmp_path / "manifest.yaml").read_text()) + assert manifest["spec"]["components"]["Command"] == { + "fromDirs": [{"path": "components/commands/"}] + } + + +def test_generate_page_component_skips_python_package_init_files(tmp_path, monkeypatch): + _write_plugin_manifest(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(gencomponent, "cli_print", lambda *args: None) + monkeypatch.setattr( + gencomponent, + "input_form_values", + lambda fields: {"page_name": "settings"}, + ) + + gencomponent.generate_component_process("Page") + + assert not (tmp_path / "components" / "__init__.py").exists() + assert not (tmp_path / "components" / "pages" / "__init__.py").exists() + assert (tmp_path / "components" / "pages" / "settings.yaml").is_file() + assert (tmp_path / "components" / "pages" / "settings.html").is_file() diff --git a/tests/cli/test_i18n_form.py b/tests/cli/test_i18n_form.py new file mode 100644 index 00000000..a1d4ec95 --- /dev/null +++ b/tests/cli/test_i18n_form.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import builtins + +from langbot_plugin.cli import i18n +from langbot_plugin.cli.utils import form + + +def test_i18n_manager_detects_locale_from_environment(monkeypatch): + monkeypatch.setenv("LC_ALL", "zh_CN.UTF-8") + + assert i18n.I18nManager().get_current_locale() == "zh_Hans" + + monkeypatch.setenv("LC_ALL", "es_ES.UTF-8") + assert i18n.I18nManager().get_current_locale() == "es_ES" + + +def test_set_locale_ignores_unsupported_locale_and_translate_falls_back_to_key(): + original = i18n.get_current_locale() + try: + i18n.set_locale("en_US") + assert i18n.get_current_locale() == "en_US" + assert i18n.t("missing_key") == "missing_key" + i18n.set_locale("not_real") + assert i18n.get_current_locale() == "en_US" + finally: + i18n.set_locale(original) + + +def test_extract_i18n_label_uses_current_locale_then_english_fallback(): + original = i18n.get_current_locale() + try: + i18n.set_locale("zh_Hans") + assert i18n.extract_i18n_label({"en_US": "Name", "zh_Hans": "名称"}) == "名称" + assert i18n.extract_i18n_label({"en_US": "Name"}) == "Name" + finally: + i18n.set_locale(original) + + +def test_input_form_values_retries_required_invalid_value(monkeypatch, capsys): + answers = iter(["Bad Value", "good_name", "description"]) + monkeypatch.setattr(builtins, "input", lambda _prompt: next(answers)) + fields = [ + { + "name": "tool_name", + "label": {"en_US": "Tool name"}, + "required": True, + "format": { + "regexp": form.NUMBER_LOWER_UNDERSCORE_REGEXP, + "error": {"en_US": "Bad tool name"}, + }, + }, + { + "name": "description", + "label": {"en_US": "Description"}, + "required": False, + }, + ] + + values = form.input_form_values(fields) + + assert values == {"tool_name": "good_name", "description": "description"} + assert "Bad tool name" in capsys.readouterr().out diff --git a/tests/cli/test_initplugin.py b/tests/cli/test_initplugin.py new file mode 100644 index 00000000..5fed614f --- /dev/null +++ b/tests/cli/test_initplugin.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import os +import subprocess + +import yaml + +from langbot_plugin.cli.commands import initplugin + + +def test_get_lbp_path_uses_platform_specific_script_location(monkeypatch): + monkeypatch.setattr(initplugin.sys, "executable", "/opt/python/bin/python") + monkeypatch.setattr(initplugin.platform, "system", lambda: "Linux") + assert initplugin.get_lbp_path() == "/opt/python/bin/lbp" + + monkeypatch.setattr(initplugin.sys, "executable", r"C:\Python\python.exe") + monkeypatch.setattr(initplugin.platform, "system", lambda: "Windows") + assert initplugin.get_lbp_path().endswith(os.path.join("Scripts", "lbp.exe")) + + +def test_is_git_available_returns_false_when_git_missing(monkeypatch): + def raise_missing(*args, **kwargs): + raise FileNotFoundError + + monkeypatch.setattr(initplugin.subprocess, "run", raise_missing) + + assert initplugin.is_git_available() is False + + +def test_init_git_repo_invokes_git_init(monkeypatch): + calls = [] + prints = [] + monkeypatch.setattr( + initplugin.subprocess, + "run", + lambda cmd, **kwargs: calls.append((cmd, kwargs)), + ) + monkeypatch.setattr(initplugin, "cli_print", lambda *args: prints.append(args)) + + initplugin.init_git_repo("plugin") + + assert calls[0][0] == ["git", "init"] + assert calls[0][1]["cwd"] == "plugin" + assert prints == [("git_repo_initialized", "plugin")] + + +def test_init_git_repo_reports_git_warning(monkeypatch): + error = subprocess.CalledProcessError(1, ["git"], stderr=b"boom") + prints = [] + monkeypatch.setattr(initplugin.subprocess, "run", lambda *args, **kwargs: (_ for _ in ()).throw(error)) + monkeypatch.setattr(initplugin, "cli_print", lambda *args: prints.append(args)) + + initplugin.init_git_repo("plugin") + + assert prints == [("git_init_warning", b"boom")] + + +def test_init_plugin_process_generates_plugin_scaffold(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + initplugin, + "input_form_values", + lambda fields: { + "plugin_author": "tester", + "plugin_description": "Demo plugin", + }, + ) + monkeypatch.setattr(initplugin, "is_git_available", lambda: False) + monkeypatch.setattr(initplugin, "cli_print", lambda *args: None) + monkeypatch.setattr(initplugin, "get_lbp_path", lambda: "/usr/bin/lbp") + + initplugin.init_plugin_process("demo-plugin") + + plugin_dir = tmp_path / "demo-plugin" + assert (plugin_dir / "manifest.yaml").is_file() + assert (plugin_dir / "main.py").is_file() + assert (plugin_dir / "assets" / "icon.svg").is_file() + assert (plugin_dir / ".vscode" / "launch.json").is_file() + manifest = yaml.safe_load((plugin_dir / "manifest.yaml").read_text()) + assert manifest["metadata"]["author"] == "tester" + assert manifest["metadata"]["name"] == "demo-plugin" + assert manifest["execution"]["python"]["attr"] == "demoplugin" + + +def test_init_plugin_process_rejects_invalid_name(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + + initplugin.init_plugin_process("bad name") + + assert "!!" in capsys.readouterr().out + assert not (tmp_path / "bad name").exists() + + +def test_init_plugin_process_rejects_non_empty_existing_directory(tmp_path, monkeypatch, capsys): + plugin_dir = tmp_path / "demo" + plugin_dir.mkdir() + (plugin_dir / "main.py").write_text("existing", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + initplugin.init_plugin_process("demo") + + assert "!!" in capsys.readouterr().out diff --git a/tests/cli/test_login.py b/tests/cli/test_login.py new file mode 100644 index 00000000..414e755b --- /dev/null +++ b/tests/cli/test_login.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +import json +import time + +import httpx + +from langbot_plugin.cli.commands import login + + +class FakeResponse: + def __init__(self, json_data, *, raise_error=False): + self._json_data = json_data + self.raise_error = raise_error + + def raise_for_status(self): + if self.raise_error: + raise httpx.HTTPStatusError("bad", request=None, response=None) + + def json(self): + return self._json_data + + +class FakeClient: + responses: list[FakeResponse] = [] + calls: list[tuple[str, dict]] = [] + + def __init__(self, timeout): + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def post(self, url, **kwargs): + self.calls.append((url, kwargs)) + return self.responses.pop(0) + + +class FailingClient: + def __init__(self, timeout): + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def post(self, url, **kwargs): + raise httpx.RequestError("network down") + + +class ErrorClient: + def __init__(self, timeout): + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def post(self, url, **kwargs): + raise RuntimeError("boom") + + +def test_save_config_creates_nested_server_config(tmp_path, monkeypatch): + monkeypatch.setattr(login.Path, "home", lambda: tmp_path) + monkeypatch.setattr(login, "SERVER_URL", "https://cloud") + + path = login._save_config({"access_token": "token"}) + + assert path == str(tmp_path / ".langbot" / "cli" / "config.json") + assert json.loads((tmp_path / ".langbot" / "cli" / "config.json").read_text()) == { + "https://cloud": {"access_token": "token"} + } + + +def test_save_config_migrates_old_flat_config(tmp_path, monkeypatch): + config_dir = tmp_path / ".langbot" / "cli" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.json" + config_file.write_text(json.dumps({"access_token": "old"}), encoding="utf-8") + monkeypatch.setattr(login.Path, "home", lambda: tmp_path) + monkeypatch.setattr(login, "SERVER_URL", "https://cloud") + + login._save_config({"access_token": "new"}) + + assert json.loads(config_file.read_text()) == { + "https://cloud": {"access_token": "new"} + } + + +def test_save_config_recovers_from_corrupt_existing_config(tmp_path, monkeypatch): + config_dir = tmp_path / ".langbot" / "cli" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.json" + config_file.write_text("{bad json", encoding="utf-8") + monkeypatch.setattr(login.Path, "home", lambda: tmp_path) + monkeypatch.setattr(login, "SERVER_URL", "https://cloud") + + login._save_config({"access_token": "new"}) + + assert json.loads(config_file.read_text()) == { + "https://cloud": {"access_token": "new"} + } + + +def test_load_config_supports_flat_and_nested_config(tmp_path, monkeypatch): + config_dir = tmp_path / ".langbot" / "cli" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.json" + monkeypatch.setattr(login.Path, "home", lambda: tmp_path) + monkeypatch.setattr(login, "SERVER_URL", "https://cloud") + + config_file.write_text(json.dumps({"access_token": "flat"}), encoding="utf-8") + assert login._load_config() == {"access_token": "flat"} + + config_file.write_text( + json.dumps({"https://cloud": {"access_token": "nested"}}), + encoding="utf-8", + ) + assert login._load_config() == {"access_token": "nested"} + + +def test_load_config_returns_none_for_missing_or_corrupt_config(tmp_path, monkeypatch): + config_dir = tmp_path / ".langbot" / "cli" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.json" + monkeypatch.setattr(login.Path, "home", lambda: tmp_path) + + assert login._load_config() is None + + config_file.write_text("{bad json", encoding="utf-8") + assert login._load_config() is None + + +def test_is_token_valid_accepts_pat_and_unexpired_oauth_token(monkeypatch): + monkeypatch.setattr(login.time, "time", lambda: 200) + + assert login._is_token_valid({"token_type": "personal_access_token"}) is True + assert login._is_token_valid({"login_time": 100, "expires_in": 200}) is True + assert login._is_token_valid({"login_time": 100, "expires_in": 50}) is False + assert login._is_token_valid({}) is False + + +def test_refresh_token_posts_refresh_token_and_persists_new_access_token( + tmp_path, monkeypatch +): + FakeClient.calls = [] + FakeClient.responses = [ + FakeResponse({"data": {"access_token": "new-token", "expires_in": 3600}}) + ] + monkeypatch.setattr(login.httpx, "Client", FakeClient) + monkeypatch.setattr(login.Path, "home", lambda: tmp_path) + monkeypatch.setattr(login, "SERVER_URL", "https://cloud") + monkeypatch.setattr(login.time, "time", lambda: 1234) + + config = {"refresh_token": "refresh-token"} + + assert login._refresh_token(config) is True + assert config == { + "refresh_token": "refresh-token", + "access_token": "new-token", + "expires_in": 3600, + "login_time": 1234, + } + assert FakeClient.calls == [ + ( + "https://cloud/api/v1/accounts/token/refresh", + {"json": {"refresh_token": "refresh-token"}}, + ) + ] + + +def test_refresh_token_returns_false_without_refresh_token(): + assert login._refresh_token({}) is False + assert login._refresh_token({"access_token": "token"}) is False + + +def test_refresh_token_returns_false_when_response_lacks_access_token(monkeypatch): + FakeClient.calls = [] + FakeClient.responses = [FakeResponse({"data": {"expires_in": 3600}})] + monkeypatch.setattr(login.httpx, "Client", FakeClient) + + assert login._refresh_token({"refresh_token": "refresh-token"}) is False + + +def test_refresh_token_reports_unexpected_failure(monkeypatch): + prints = [] + monkeypatch.setattr(login.httpx, "Client", ErrorClient) + monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args)) + + assert login._refresh_token({"refresh_token": "refresh-token"}) is False + assert len(prints) == 1 + assert prints[0][0] == "token_refresh_failed" + + +def test_generate_device_code_posts_to_token_generate(monkeypatch): + FakeClient.calls = [] + FakeClient.responses = [FakeResponse({"code": 0, "data": {"device_code": "d"}})] + monkeypatch.setattr(login.httpx, "Client", FakeClient) + + assert login._generate_device_code("https://cloud/api/v1") == { + "code": 0, + "data": {"device_code": "d"}, + } + assert FakeClient.calls == [ + ("https://cloud/api/v1/accounts/token/generate", {}) + ] + + +def test_generate_device_code_reports_network_failure(monkeypatch): + monkeypatch.setattr(login.httpx, "Client", FailingClient) + monkeypatch.setattr(login, "t", lambda key, error: f"{key}: {error}") + + assert login._generate_device_code("https://cloud/api/v1") == { + "code": -1, + "msg": "network_request_failed: network down", + } + + +def test_generate_device_code_reports_unexpected_failure(monkeypatch): + monkeypatch.setattr(login.httpx, "Client", ErrorClient) + monkeypatch.setattr(login, "t", lambda key, error: f"{key}: {error}") + + assert login._generate_device_code("https://cloud/api/v1") == { + "code": -1, + "msg": "device_code_failed: boom", + } + + +def test_poll_for_token_returns_token_data(monkeypatch): + FakeClient.calls = [] + FakeClient.responses = [ + FakeResponse({"code": 0, "data": {"access_token": "token"}}) + ] + monkeypatch.setattr(login.httpx, "Client", FakeClient) + monkeypatch.setattr(login.time, "time", lambda: 0) + + assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 10) == { + "access_token": "token" + } + assert FakeClient.calls == [ + ( + "https://cloud/api/v1/accounts/token/get", + {"json": {"device_code": "dev", "user_code": "user"}}, + ) + ] + + +def test_poll_for_token_waits_while_authorization_is_pending(monkeypatch): + sleeps = [] + times = iter([0, 0, 1]) + FakeClient.calls = [] + FakeClient.responses = [ + FakeResponse({"code": 425, "msg": "pending"}), + FakeResponse({"code": 0, "data": {"access_token": "token"}}), + ] + monkeypatch.setattr(login.httpx, "Client", FakeClient) + monkeypatch.setattr(login.time, "time", lambda: next(times)) + monkeypatch.setattr(login.time, "sleep", lambda seconds: sleeps.append(seconds)) + + assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 10) == { + "access_token": "token" + } + assert sleeps == [3] + + +def test_poll_for_token_reports_non_pending_api_failure(monkeypatch): + prints = [] + FakeClient.calls = [] + FakeClient.responses = [FakeResponse({"code": 400, "msg": "bad code"})] + monkeypatch.setattr(login.httpx, "Client", FakeClient) + monkeypatch.setattr(login.time, "time", lambda: 0) + monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args)) + + assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 10) is None + assert prints == [("token_get_failed", "bad code")] + + +def test_poll_for_token_reports_network_failure(monkeypatch): + prints = [] + monkeypatch.setattr(login.httpx, "Client", FailingClient) + monkeypatch.setattr(login.time, "time", lambda: 0) + monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args)) + + assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 10) is None + assert len(prints) == 1 + assert prints[0][0] == "network_request_failed" + + +def test_poll_for_token_reports_unexpected_failure(monkeypatch): + prints = [] + monkeypatch.setattr(login.httpx, "Client", ErrorClient) + monkeypatch.setattr(login.time, "time", lambda: 0) + monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args)) + + assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 10) is None + assert len(prints) == 1 + assert prints[0][0] == "token_check_failed" + + +def test_poll_for_token_returns_none_after_timeout(monkeypatch): + times = iter([0, 31]) + monkeypatch.setattr(login.time, "time", lambda: next(times)) + + assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 0) is None + + +def test_login_process_rejects_invalid_personal_access_token(monkeypatch): + prints = [] + monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args)) + + login.login_process("bad-token") + + assert prints == [("pat_invalid_format",)] + + +def test_login_process_saves_personal_access_token(tmp_path, monkeypatch): + prints = [] + monkeypatch.setattr(login.Path, "home", lambda: tmp_path) + monkeypatch.setattr(login, "SERVER_URL", "https://cloud") + monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args)) + monkeypatch.setattr(login.time, "time", lambda: 1234) + + login.login_process("lbpat_secret") + + config_file = tmp_path / ".langbot" / "cli" / "config.json" + assert json.loads(config_file.read_text())["https://cloud"] == { + "access_token": "lbpat_secret", + "token_type": "personal_access_token", + "login_time": 1234, + "expires_in": 0, + } + assert ("pat_login_successful",) in prints + assert ("pat_saved", str(config_file)) in prints + + +def test_login_process_reports_device_code_failure(monkeypatch): + prints = [] + monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args)) + monkeypatch.setattr( + login, + "_generate_device_code", + lambda api_base: {"code": 1, "msg": "denied"}, + ) + + login.login_process() + + assert prints == [ + ("starting_login",), + ("generating_device_code",), + ("device_code_failed", "denied"), + ] + + +def test_login_process_reports_timeout_when_poll_returns_no_token(monkeypatch): + prints = [] + monkeypatch.setattr(login, "SERVER_URL", "https://cloud") + monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args)) + monkeypatch.setattr( + login, + "_generate_device_code", + lambda api_base: { + "code": 0, + "data": { + "device_code": "dev", + "user_code": "user", + "verification_uri": "/verify", + "expires_in": 60, + }, + }, + ) + monkeypatch.setattr( + login, + "_poll_for_token", + lambda api_base, device_code, user_code, interval, expires_in: None, + ) + + login.login_process() + + assert prints[-1] == ("login_timeout",) + + +def test_login_process_saves_device_flow_token(monkeypatch): + prints = [] + saved_configs = [] + monkeypatch.setattr(login, "SERVER_URL", "https://cloud") + monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args)) + monkeypatch.setattr(login.time, "time", lambda: 1234) + monkeypatch.setattr( + login, + "_generate_device_code", + lambda api_base: { + "code": 0, + "data": { + "device_code": "dev", + "user_code": "user", + "verification_uri": "/verify", + "expires_in": 60, + }, + }, + ) + monkeypatch.setattr( + login, + "_poll_for_token", + lambda api_base, device_code, user_code, interval, expires_in: { + "access_token": "access", + "refresh_token": "refresh", + "expires_in": 3600, + "token_type": "Bearer", + }, + ) + monkeypatch.setattr( + login, + "_save_config", + lambda config: saved_configs.append(config) or "/tmp/config.json", + ) + + login.login_process() + + assert saved_configs == [ + { + "access_token": "access", + "refresh_token": "refresh", + "expires_in": 3600, + "token_type": "Bearer", + "login_time": 1234, + } + ] + assert ("login_successful",) in prints + assert ("token_saved", "/tmp/config.json") in prints + assert ("token_type_label", "Bearer") in prints + assert ("expires_in_label", 3600) in prints + + +def test_login_process_reports_unexpected_error(monkeypatch): + prints = [] + error = RuntimeError("boom") + monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args)) + monkeypatch.setattr( + login, + "_generate_device_code", + lambda api_base: (_ for _ in ()).throw(error), + ) + + login.login_process() + + assert prints == [ + ("starting_login",), + ("generating_device_code",), + ("login_error", error), + ] + + +def test_check_login_status_refreshes_expired_token(tmp_path, monkeypatch): + config_dir = tmp_path / ".langbot" / "cli" + config_dir.mkdir(parents=True) + (config_dir / "config.json").write_text( + json.dumps( + { + "https://cloud": { + "refresh_token": "refresh-token", + "login_time": int(time.time()) - 100, + "expires_in": 1, + } + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(login.Path, "home", lambda: tmp_path) + monkeypatch.setattr(login, "SERVER_URL", "https://cloud") + monkeypatch.setattr(login, "_refresh_token", lambda config: True) + + assert login.check_login_status() is True + + +def test_get_access_token_returns_only_valid_token(tmp_path, monkeypatch): + config_dir = tmp_path / ".langbot" / "cli" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.json" + monkeypatch.setattr(login.Path, "home", lambda: tmp_path) + monkeypatch.setattr(login, "SERVER_URL", "https://cloud") + monkeypatch.setattr(login.time, "time", lambda: 200) + + config_file.write_text( + json.dumps( + { + "https://cloud": { + "access_token": "token", + "login_time": 100, + "expires_in": 200, + } + } + ), + encoding="utf-8", + ) + assert login.get_access_token() == "token" + + config_file.write_text( + json.dumps( + { + "https://cloud": { + "access_token": "expired", + "login_time": 100, + "expires_in": 50, + } + } + ), + encoding="utf-8", + ) + assert login.get_access_token() is None diff --git a/tests/cli/test_logout_publish.py b/tests/cli/test_logout_publish.py new file mode 100644 index 00000000..861068c4 --- /dev/null +++ b/tests/cli/test_logout_publish.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json + +import httpx + +from langbot_plugin.cli.commands import logout, publish + + +def test_logout_process_reports_already_logged_out_when_config_missing( + tmp_path, monkeypatch +): + prints = [] + monkeypatch.setattr(logout.Path, "home", lambda: tmp_path) + monkeypatch.setattr(logout, "cli_print", lambda *args: prints.append(args)) + + logout.logout_process() + + assert prints == [("already_logged_out",)] + + +def test_logout_process_removes_old_flat_token_config(tmp_path, monkeypatch): + config_dir = tmp_path / ".langbot" / "cli" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.json" + config_file.write_text(json.dumps({"access_token": "token"}), encoding="utf-8") + prints = [] + monkeypatch.setattr(logout.Path, "home", lambda: tmp_path) + monkeypatch.setattr(logout, "cli_print", lambda *args: prints.append(args)) + + logout.logout_process() + + assert not config_file.exists() + assert prints[0] == ("logout_successful",) + + +def test_logout_process_removes_current_server_from_nested_config(tmp_path, monkeypatch): + config_dir = tmp_path / ".langbot" / "cli" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.json" + config_file.write_text( + json.dumps( + { + "https://cloud": {"access_token": "token"}, + "https://other": {"access_token": "other"}, + } + ), + encoding="utf-8", + ) + prints = [] + monkeypatch.setattr(logout.Path, "home", lambda: tmp_path) + monkeypatch.setattr(logout, "SERVER_URL", "https://cloud") + monkeypatch.setattr(logout, "cli_print", lambda *args: prints.append(args)) + + logout.logout_process() + + assert json.loads(config_file.read_text()) == { + "https://other": {"access_token": "other"} + } + assert prints == [("logout_successful",)] + + +def test_logout_process_removes_file_when_last_nested_credential_is_deleted( + tmp_path, monkeypatch +): + config_dir = tmp_path / ".langbot" / "cli" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.json" + config_file.write_text( + json.dumps({"https://cloud": {"access_token": "token"}}), + encoding="utf-8", + ) + prints = [] + monkeypatch.setattr(logout.Path, "home", lambda: tmp_path) + monkeypatch.setattr(logout, "SERVER_URL", "https://cloud") + monkeypatch.setattr(logout, "cli_print", lambda *args: prints.append(args)) + + logout.logout_process() + + assert not config_file.exists() + assert prints[0] == ("logout_successful",) + + +class FakeResponse: + def __init__(self, json_data, *, raise_error=False): + self._json_data = json_data + self.raise_error = raise_error + + def raise_for_status(self): + if self.raise_error: + raise httpx.HTTPStatusError("bad", request=None, response=None) + + def json(self): + return self._json_data + + +class FakeClient: + calls = [] + response = FakeResponse({"code": 0, "data": {"submission": {"status": "live"}}}) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def post(self, url, files, data, headers, timeout): + self.calls.append( + { + "url": url, + "file_name": files["file"].name, + "data": data, + "headers": headers, + "timeout": timeout, + } + ) + return self.response + + +def test_publish_plugin_posts_package_with_bearer_token(tmp_path, monkeypatch): + package = tmp_path / "plugin.lbpkg" + package.write_bytes(b"package") + prints = [] + FakeClient.calls = [] + FakeClient.response = FakeResponse( + {"code": 0, "data": {"submission": {"status": "live"}}} + ) + monkeypatch.setattr(publish.httpx, "Client", FakeClient) + monkeypatch.setattr(publish, "SERVER_URL", "https://cloud") + monkeypatch.setattr(publish, "cli_print", lambda *args: prints.append(args)) + + publish.publish_plugin(str(package), "change", "token") + + assert FakeClient.calls == [ + { + "url": "https://cloud/api/v1/marketplace/plugins/publish", + "file_name": str(package), + "data": {"changelog": "change"}, + "headers": {"Authorization": "Bearer token"}, + "timeout": 300, + } + ] + assert prints == [("publish_successful", "https://cloud")] + + +def test_publish_plugin_reports_api_failure(tmp_path, monkeypatch): + package = tmp_path / "plugin.lbpkg" + package.write_bytes(b"package") + prints = [] + FakeClient.calls = [] + FakeClient.response = FakeResponse({"code": 1, "msg": "nope"}) + monkeypatch.setattr(publish.httpx, "Client", FakeClient) + monkeypatch.setattr(publish, "cli_print", lambda *args: prints.append(args)) + + publish.publish_plugin(str(package), "", "token") + + assert prints == [("publish_failed", "nope")] + + +def test_publish_process_requires_login(monkeypatch): + prints = [] + monkeypatch.setattr(publish, "check_login_status", lambda: False) + monkeypatch.setattr(publish, "cli_print", lambda *args: prints.append(args)) + + publish.publish_process() + + assert prints == [("not_logged_in",)] + + +def test_publish_process_builds_publishes_and_cleans_tmp_dir(monkeypatch): + calls = [] + monkeypatch.setattr(publish, "check_login_status", lambda: True) + monkeypatch.setattr(publish, "get_access_token", lambda: "token") + monkeypatch.setattr(publish, "build_plugin_process", lambda output_dir: calls.append(("build", output_dir)) or "pkg") + monkeypatch.setattr(publish, "publish_plugin", lambda path, changelog, token: calls.append(("publish", path, changelog, token))) + monkeypatch.setattr(publish.shutil, "rmtree", lambda path: calls.append(("rmtree", path))) + + publish.publish_process() + + assert calls == [ + ("build", publish.TMP_DIR), + ("publish", "pkg", "", "token"), + ("rmtree", publish.TMP_DIR), + ] diff --git a/tests/cli/test_page_components.py b/tests/cli/test_page_components.py new file mode 100644 index 00000000..7d25e91a --- /dev/null +++ b/tests/cli/test_page_components.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from langbot_plugin.api.definition.components.manifest import ComponentManifest +from langbot_plugin.cli.utils.page_components import populate_plugin_pages + + +def _manifest(kind: str, name: str, rel_path: str, spec=None) -> ComponentManifest: + return ComponentManifest( + owner="tester", + manifest={ + "apiVersion": "v1", + "kind": kind, + "metadata": { + "name": name, + "label": {"en_US": name.title()}, + }, + "spec": spec or {}, + }, + rel_path=rel_path, + ) + + +def test_populate_plugin_pages_merges_existing_and_component_pages(): + plugin = _manifest( + "Plugin", + "demo", + "manifest.yaml", + spec={ + "components": {}, + "pages": [{"id": "existing", "label": {"en_US": "Existing"}, "path": "x"}], + }, + ) + component = _manifest( + "Page", + "settings", + "components/pages/settings.yaml", + spec={"path": "settings.html"}, + ) + + populate_plugin_pages(plugin, [component]) + + assert plugin.manifest["spec"]["pages"] == [ + {"id": "existing", "label": {"en_US": "Existing"}, "path": "x"}, + { + "id": "settings", + "label": {"en_US": "Settings"}, + "path": "components/pages/settings.html", + }, + ] + + +def test_populate_plugin_pages_deduplicates_page_ids(): + plugin = _manifest( + "Plugin", + "demo", + "manifest.yaml", + spec={"components": {}, "pages": [{"id": "settings", "path": "existing"}]}, + ) + component = _manifest("Page", "settings", "components/pages/settings.yaml") + + populate_plugin_pages(plugin, [component]) + + assert plugin.manifest["spec"]["pages"] == [{"id": "settings", "path": "existing"}] diff --git a/tests/cli/test_renderer.py b/tests/cli/test_renderer.py new file mode 100644 index 00000000..72e76567 --- /dev/null +++ b/tests/cli/test_renderer.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from langbot_plugin.cli.gen import renderer + + +def test_component_input_post_processors_create_python_class_names(): + assert renderer.tool_component_input_post_process( + {"tool_name": "web_search", "tool_description": "Search the web"} + ) == { + "tool_name": "web_search", + "tool_label": "WebSearch", + "tool_description": "Search the web", + "tool_attr": "WebSearch", + } + assert renderer.command_component_input_post_process( + {"cmd_name": "hello_world", "cmd_description": "Say hello"} + )["cmd_attr"] == "HelloWorld" + assert renderer.knowledge_engine_component_input_post_process( + { + "knowledge_engine_name": "local_docs", + "knowledge_engine_description": "Docs", + } + )["knowledge_engine_attr"] == "LocalDocs" + assert renderer.parser_component_input_post_process( + {"parser_name": "pdf_reader", "parser_description": "PDF"} + )["parser_label"] == "PdfReader" + assert renderer.page_component_input_post_process({"page_name": "settings_page"}) == { + "page_name": "settings_page", + "page_label": "SettingsPage", + } + + +def test_component_type_registry_contains_expected_public_component_kinds(): + by_name = {component.type_name: component for component in renderer.component_types} + + assert set(by_name) == { + "EventListener", + "Tool", + "Command", + "KnowledgeEngine", + "Parser", + "Page", + } + assert by_name["Tool"].target_dir == "components/tools" + assert "{tool_name}.py" in by_name["Tool"].template_files + assert by_name["Page"].target_dir == "components/pages" + + +def test_simple_render_uses_python_format_context(): + assert renderer.simple_render("hello {name}", name="plugin") == "hello plugin" + + +def test_render_template_loads_packaged_templates(): + rendered = renderer.render_template( + "components/tools/{tool_name}.yaml.example", + tool_name="weather", + tool_label="Weather", + tool_description="Lookup weather", + tool_attr="Weather", + ) + + assert "name: weather" in rendered + assert "Weather" in rendered + assert "Lookup weather" in rendered diff --git a/tests/cli/test_runplugin.py b/tests/cli/test_runplugin.py new file mode 100644 index 00000000..52310f66 --- /dev/null +++ b/tests/cli/test_runplugin.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import asyncio + +from langbot_plugin.cli.commands import runplugin + + +class FakeDiscoveryEngine: + manifest = object() + load_calls = [] + + def load_component_manifest(self, **kwargs): + self.load_calls.append(kwargs) + return self.manifest + + +class FakeRuntimeController: + instances = [] + + def __init__( + self, + plugin_manifest, + component_manifests, + stdio, + ws_debug_url, + prod_mode, + ): + self.plugin_manifest = plugin_manifest + self.component_manifests = component_manifests + self.stdio = stdio + self.ws_debug_url = ws_debug_url + self.prod_mode = prod_mode + self.calls = [] + self.instances.append(self) + + async def run(self): + self.calls.append("run") + + async def mount(self): + self.calls.append("mount") + + +async def test_arun_plugin_process_reports_missing_manifest(tmp_path, monkeypatch): + prints = [] + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(runplugin, "cli_print", lambda *args: prints.append(args)) + + await runplugin.arun_plugin_process(stdio=True) + + assert prints == [("manifest_not_found",)] + + +async def test_arun_plugin_process_reports_manifest_load_failure(tmp_path, monkeypatch): + prints = [] + (tmp_path / "manifest.yaml").write_text("kind: Plugin\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(runplugin, "cli_print", lambda *args: prints.append(args)) + monkeypatch.setattr( + runplugin, + "ComponentDiscoveryEngine", + lambda: type( + "MissingManifestDiscovery", + (), + {"load_component_manifest": lambda self, **kwargs: None}, + )(), + ) + + await runplugin.arun_plugin_process(stdio=True) + + assert prints == [("manifest_not_found",)] + + +async def test_arun_plugin_process_requires_debug_url_for_websocket_mode( + tmp_path, monkeypatch +): + prints = [] + (tmp_path / "manifest.yaml").write_text("kind: Plugin\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEBUG_RUNTIME_WS_URL", raising=False) + monkeypatch.delenv("RUNTIME_WS_URL", raising=False) + monkeypatch.setattr(runplugin, "cli_print", lambda *args: prints.append(args)) + monkeypatch.setattr(runplugin, "ComponentDiscoveryEngine", FakeDiscoveryEngine) + + await runplugin.arun_plugin_process(stdio=False) + + assert prints == [("debug_url_not_set",)] + + +async def test_arun_plugin_process_builds_controller_and_sets_runtime_env( + tmp_path, monkeypatch +): + calls = [] + manifest = object() + components = [object()] + (tmp_path / "manifest.yaml").write_text("kind: Plugin\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("PLUGIN_DEBUG_KEY", raising=False) + monkeypatch.delenv("LANGBOT_PLUGIN_PYPI_INDEX_URL", raising=False) + monkeypatch.delenv("LANGBOT_PLUGIN_PYPI_TRUSTED_HOST", raising=False) + monkeypatch.setattr( + runplugin.dotenv, + "load_dotenv", + lambda path: calls.append(("load_dotenv", path)), + ) + monkeypatch.setattr( + runplugin, + "ComponentDiscoveryEngine", + lambda: type( + "Discovery", + (), + { + "load_component_manifest": ( + lambda self, **kwargs: calls.append(("load_manifest", kwargs)) + or manifest + ) + }, + )(), + ) + monkeypatch.setattr( + runplugin, + "discover_plugin_components", + lambda plugin_manifest, engine: calls.append( + ("discover_components", plugin_manifest, engine) + ) + or components, + ) + monkeypatch.setattr( + runplugin, + "populate_plugin_pages", + lambda plugin_manifest, component_manifests: calls.append( + ("populate_pages", plugin_manifest, component_manifests) + ), + ) + monkeypatch.setattr(runplugin, "PluginRuntimeController", FakeRuntimeController) + + await runplugin.arun_plugin_process( + stdio=True, + prod_mode=True, + plugin_debug_key="debug-key", + pypi_index_url="https://mirror", + pypi_trusted_host="mirror", + ) + + controller = FakeRuntimeController.instances[-1] + assert calls[0] == ("load_dotenv", ".env") + assert calls[1] == ( + "load_manifest", + {"path": "manifest.yaml", "owner": "builtin", "no_save": True}, + ) + assert calls[2][0] == "discover_components" + assert calls[3] == ("populate_pages", manifest, components) + assert controller.plugin_manifest is manifest + assert controller.component_manifests is components + assert controller.stdio is True + assert controller.ws_debug_url == "" + assert controller.prod_mode is True + assert controller.calls == ["mount", "run"] + assert runplugin.os.environ["PLUGIN_DEBUG_KEY"] == "debug-key" + assert runplugin.os.environ["LANGBOT_PLUGIN_PYPI_INDEX_URL"] == "https://mirror" + assert runplugin.os.environ["LANGBOT_PLUGIN_PYPI_TRUSTED_HOST"] == "mirror" + + +async def test_arun_plugin_process_uses_debug_runtime_ws_url(tmp_path, monkeypatch): + components = [] + manifest = object() + (tmp_path / "manifest.yaml").write_text("kind: Plugin\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("DEBUG_RUNTIME_WS_URL", "ws://debug") + monkeypatch.setenv("RUNTIME_WS_URL", "ws://runtime") + monkeypatch.setattr( + runplugin, + "ComponentDiscoveryEngine", + lambda: type( + "Discovery", + (), + {"load_component_manifest": lambda self, **kwargs: manifest}, + )(), + ) + monkeypatch.setattr( + runplugin, + "discover_plugin_components", + lambda plugin_manifest, engine: components, + ) + monkeypatch.setattr(runplugin, "populate_plugin_pages", lambda *args: None) + monkeypatch.setattr(runplugin, "PluginRuntimeController", FakeRuntimeController) + + await runplugin.arun_plugin_process(stdio=False) + + assert FakeRuntimeController.instances[-1].ws_debug_url == "ws://debug" + + +def test_run_plugin_process_configures_logging_and_runs_async_entry(monkeypatch): + calls = [] + monkeypatch.setattr( + runplugin, + "configure_process_logging", + lambda: calls.append(("configure_logging",)), + ) + monkeypatch.setattr( + runplugin, + "arun_plugin_process", + lambda *args: calls.append(("arun", args)) or "coroutine", + ) + monkeypatch.setattr( + runplugin.asyncio, + "run", + lambda coroutine: calls.append(("asyncio_run", coroutine)), + ) + + runplugin.run_plugin_process( + stdio=True, + prod_mode=True, + plugin_debug_key="debug-key", + pypi_index_url="https://mirror", + pypi_trusted_host="mirror", + ) + + assert calls == [ + ("configure_logging",), + ( + "arun", + (True, True, "debug-key", "https://mirror", "mirror"), + ), + ("asyncio_run", "coroutine"), + ] + + +def test_run_plugin_process_reports_cancelled_error(monkeypatch): + prints = [] + monkeypatch.setattr(runplugin, "configure_process_logging", lambda: None) + monkeypatch.setattr(runplugin, "arun_plugin_process", lambda *args: "coroutine") + monkeypatch.setattr( + runplugin.asyncio, + "run", + lambda coroutine: (_ for _ in ()).throw(asyncio.CancelledError()), + ) + monkeypatch.setattr(runplugin, "cli_print", lambda *args: prints.append(args)) + + runplugin.run_plugin_process() + + assert prints == [("plugin_process_cancelled",)] + + +def test_run_plugin_process_reports_keyboard_interrupt(monkeypatch): + prints = [] + monkeypatch.setattr(runplugin, "configure_process_logging", lambda: None) + monkeypatch.setattr(runplugin, "arun_plugin_process", lambda *args: "coroutine") + monkeypatch.setattr( + runplugin.asyncio, + "run", + lambda coroutine: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + monkeypatch.setattr(runplugin, "cli_print", lambda *args: prints.append(args)) + + runplugin.run_plugin_process() + + assert prints == [("keyboard_interrupt",)] diff --git a/tests/entities/io/test_protocol.py b/tests/entities/io/test_protocol.py new file mode 100644 index 00000000..2d6011cc --- /dev/null +++ b/tests/entities/io/test_protocol.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from langbot_plugin.entities.io.actions.enums import ( + CommonAction, + LangBotToRuntimeAction, + PluginToRuntimeAction, + RuntimeToLangBotAction, + RuntimeToPluginAction, +) +from langbot_plugin.entities.io.errors import ( + ActionCallError, + ActionCallTimeoutError, + ConnectionClosedError, +) +from langbot_plugin.entities.io.req import ActionRequest +from langbot_plugin.entities.io.resp import ActionResponse, ChunkStatus + + +def test_action_request_factory_preserves_protocol_fields(): + request = ActionRequest.make_request( + seq_id=42, + action=PluginToRuntimeAction.GET_BOT_UUID.value, + data={"query_id": 1001}, + ) + + assert request.seq_id == 42 + assert request.action == "get_bot_uuid" + assert request.data == {"query_id": 1001} + assert request.model_dump() == { + "seq_id": 42, + "action": "get_bot_uuid", + "data": {"query_id": 1001}, + } + + +def test_action_request_requires_mapping_data(): + with pytest.raises(ValidationError): + ActionRequest(seq_id=1, action="ping", data=["not", "a", "dict"]) + + +def test_action_response_success_error_and_chunk_serialization(): + success = ActionResponse.success({"ok": True}) + assert success.seq_id == 0 + assert success.code == 0 + assert success.message == "success" + assert success.model_dump()["chunk_status"] == "continue" + + error = ActionResponse.error("boom") + assert error.seq_id is None + assert error.code == 1 + assert error.data == {} + + end = ActionResponse( + seq_id=99, + code=0, + message="done", + data={}, + chunk_status=ChunkStatus.END, + ) + dumped = end.model_dump() + assert dumped["chunk_status"] == "end" + assert ActionResponse.model_validate(dumped).chunk_status is ChunkStatus.END + + +def test_action_response_normalizes_missing_chunk_status_to_continue(): + response = ActionResponse(seq_id=1, code=0, message="ok", data={}, chunk_status=None) + assert response.chunk_status is ChunkStatus.CONTINUE + + +def test_protocol_error_messages_are_stable_strings(): + assert str(ConnectionClosedError("closed")) == "closed" + assert str(ActionCallTimeoutError("slow")) == "slow" + assert str(ActionCallError("failed")) == "failed" + + +def test_action_values_are_unique_inside_each_protocol_direction(): + for action_group in ( + CommonAction, + PluginToRuntimeAction, + RuntimeToPluginAction, + LangBotToRuntimeAction, + RuntimeToLangBotAction, + ): + values = [action.value for action in action_group] + assert len(values) == len(set(values)), action_group.__name__ diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/helpers/protocol.py b/tests/helpers/protocol.py new file mode 100644 index 00000000..2a413f68 --- /dev/null +++ b/tests/helpers/protocol.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import asyncio +import json +from typing import Any + +from langbot_plugin.entities.io.errors import ConnectionClosedError +from langbot_plugin.runtime.io.connection import Connection +from langbot_plugin.runtime.io.handler import Handler + + +class ProtocolConnection(Connection): + def __init__(self): + self.incoming: asyncio.Queue[str | BaseException] = asyncio.Queue() + self.sent: list[str] = [] + self.sent_event = asyncio.Event() + self.closed = False + + async def send(self, message: str) -> None: + self.sent.append(message) + self.sent_event.set() + + async def receive(self) -> str: + message = await self.incoming.get() + if isinstance(message, BaseException): + raise message + return message + + async def close(self) -> None: + self.closed = True + + async def send_peer_request( + self, + action: str, + data: dict[str, Any] | None = None, + seq_id: int = 1, + ) -> None: + await self.incoming.put( + json.dumps({"seq_id": seq_id, "action": action, "data": data or {}}) + ) + + async def send_peer_response( + self, + seq_id: int, + code: int = 0, + message: str = "success", + data: dict[str, Any] | None = None, + chunk_status: str = "continue", + ) -> None: + await self.incoming.put( + json.dumps( + { + "seq_id": seq_id, + "code": code, + "message": message, + "data": data or {}, + "chunk_status": chunk_status, + } + ) + ) + + async def close_peer(self) -> None: + await self.incoming.put(ConnectionClosedError("closed")) + + async def sent_messages(self, count: int = 1) -> list[dict[str, Any]]: + for _ in range(50): + if len(self.sent) >= count: + return [json.loads(message) for message in self.sent[:count]] + await asyncio.sleep(0.01) + raise AssertionError(f"timed out waiting for {count} sent messages") + + +class ProtocolSession: + def __init__(self, handler: Handler): + self.handler = handler + self.connection = handler.conn + assert isinstance(self.connection, ProtocolConnection) + self._task: asyncio.Task | None = None + + async def __aenter__(self): + self._task = asyncio.create_task(self.handler.run()) + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.connection.close_peer() + if self._task is not None: + await self._task + return False + + async def request( + self, + action: str, + data: dict[str, Any] | None = None, + seq_id: int = 1, + ) -> dict[str, Any]: + start = len(self.connection.sent) + await self.connection.send_peer_request(action, data, seq_id) + for _ in range(50): + if len(self.connection.sent) > start: + return json.loads(self.connection.sent[-1]) + await asyncio.sleep(0.01) + raise AssertionError(f"timed out waiting for response to {action}") + + async def request_messages( + self, + action: str, + data: dict[str, Any] | None = None, + seq_id: int = 1, + count: int = 1, + ) -> list[dict[str, Any]]: + start = len(self.connection.sent) + await self.connection.send_peer_request(action, data, seq_id) + for _ in range(50): + if len(self.connection.sent) >= start + count: + return [ + json.loads(message) + for message in self.connection.sent[start : start + count] + ] + await asyncio.sleep(0.01) + raise AssertionError(f"timed out waiting for {count} responses to {action}") diff --git a/tests/runtime/helper/test_marketplace.py b/tests/runtime/helper/test_marketplace.py new file mode 100644 index 00000000..c39de1c6 --- /dev/null +++ b/tests/runtime/helper/test_marketplace.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import pytest + +from langbot_plugin.runtime.helper import marketplace + + +def _plugin_payload(): + return { + "created_at": "2025-08-10T21:29:28.54938+08:00", + "updated_at": "2025-08-11T14:17:19.223492+08:00", + "deleted_at": None, + "plugin_id": "tester/demo", + "author": "tester", + "name": "demo", + "label": {"en_US": "Demo"}, + "description": {"en_US": "Demo plugin"}, + "icon": "icon.svg", + "repository": "https://example.com/repo", + "tags": None, + "install_count": 1, + "latest_version": "0.1.0", + "status": "live", + } + + +class FakeResponse: + def __init__(self, *, status_code=200, json_data=None, content=b""): + self.status_code = status_code + self._json_data = json_data or {} + self.content = content + self.text = "response text" + self.headers = {"content-length": str(len(content))} + + def json(self): + return self._json_data + + async def aiter_bytes(self, chunk_size=8192): + for i in range(0, len(self.content), chunk_size): + yield self.content[i : i + chunk_size] + + +class FakeStream: + def __init__(self, response): + self.response = response + + async def __aenter__(self): + return self.response + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class FakeAsyncClient: + response = FakeResponse() + requests = [] + + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url): + self.requests.append(("GET", url)) + return self.response + + def stream(self, method, url): + self.requests.append((method, url)) + return FakeStream(self.response) + + +@pytest.fixture(autouse=True) +def fake_client(monkeypatch): + FakeAsyncClient.requests = [] + monkeypatch.setattr(marketplace.httpx, "AsyncClient", FakeAsyncClient) + monkeypatch.setattr(marketplace.runtime_settings, "cloud_service_url", "https://cloud") + return FakeAsyncClient + + +@pytest.mark.asyncio +async def test_get_plugin_info_validates_marketplace_response(fake_client): + fake_client.response = FakeResponse( + json_data={"code": 0, "data": {"plugin": _plugin_payload()}} + ) + + info = await marketplace.get_plugin_info("tester", "demo") + + assert info.plugin_id == "tester/demo" + assert fake_client.requests == [ + ("GET", "https://cloud/api/v1/marketplace/plugins/tester/demo") + ] + + +@pytest.mark.asyncio +async def test_download_plugin_returns_response_content(fake_client): + fake_client.response = FakeResponse(content=b"package") + + assert await marketplace.download_plugin("tester", "demo", "0.1.0") == b"package" + + +@pytest.mark.asyncio +async def test_download_plugin_streaming_yields_progress_and_final_data(fake_client): + fake_client.response = FakeResponse(content=b"abcdef") + + chunks = [ + chunk + async for chunk in marketplace.download_plugin_streaming( + "tester", "demo", "0.1.0" + ) + ] + + assert chunks[0]["downloaded"] == 6 + assert chunks[0]["done"] is False + assert chunks[-1]["done"] is True + assert chunks[-1]["data"] == b"abcdef" + + +@pytest.mark.asyncio +async def test_list_plugins_validates_each_plugin(fake_client): + fake_client.response = FakeResponse( + json_data={"code": 0, "data": {"plugins": [_plugin_payload()]}} + ) + + plugins = await marketplace.list_plugins() + + assert [plugin.plugin_id for plugin in plugins] == ["tester/demo"] diff --git a/tests/runtime/helper/test_pkgmgr.py b/tests/runtime/helper/test_pkgmgr.py new file mode 100644 index 00000000..7abf90c2 --- /dev/null +++ b/tests/runtime/helper/test_pkgmgr.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import asyncio + +from langbot_plugin.runtime.helper import pkgmgr + + +def test_get_pip_index_args_defaults_to_official_pypi(monkeypatch): + monkeypatch.delenv(pkgmgr.PYPI_INDEX_URL_ENV, raising=False) + monkeypatch.delenv(pkgmgr.PYPI_TRUSTED_HOST_ENV, raising=False) + + assert pkgmgr.get_pip_index_args() == ["-i", pkgmgr.DEFAULT_PYPI_INDEX_URL] + + +def test_get_pip_index_args_reads_custom_index_and_trusted_hosts(monkeypatch): + monkeypatch.setenv(pkgmgr.PYPI_INDEX_URL_ENV, "https://mirror/simple") + monkeypatch.setenv(pkgmgr.PYPI_TRUSTED_HOST_ENV, "mirror.local, cache.local ") + + assert pkgmgr.get_pip_index_args() == [ + "-i", + "https://mirror/simple", + "--trusted-host", + "mirror.local", + "--trusted-host", + "cache.local", + ] + + +def test_parse_requirements_ignores_comments_blank_lines_and_options(tmp_path): + requirements = tmp_path / "requirements.txt" + requirements.write_text( + "\n# comment\nrequests>=2\n-r base.txt\n--index-url https://mirror\npydantic\n", + encoding="utf-8", + ) + + assert pkgmgr.parse_requirements(str(requirements)) == ["requests>=2", "pydantic"] + + +def test_parse_downloaded_bytes_supports_common_pip_units(): + output = "\n".join( + [ + "Downloading a.whl (1.5 kB)", + "Downloading b.whl (2 MB)", + "Downloading c.whl (3 bytes)", + ] + ) + + assert pkgmgr._parse_downloaded_bytes(output) == int(1.5 * 1024) + 2 * 1024 * 1024 + 3 + + +def test_install_single_builds_pip_command_and_returns_parsed_download_size(monkeypatch): + class Result: + returncode = 0 + stdout = "Downloading pkg.whl (1 kB)" + stderr = "" + + calls = [] + monkeypatch.setattr(pkgmgr, "get_pip_index_args", lambda: ["-i", "https://mirror"]) + monkeypatch.setattr(pkgmgr.subprocess, "run", lambda cmd, **kwargs: calls.append((cmd, kwargs)) or Result()) + + returncode, downloaded, output = pkgmgr.install_single("demo", ["--no-deps"]) + + assert returncode == 0 + assert downloaded == 1024 + assert "Downloading pkg.whl" in output + assert calls[0][0][-4:] == ["demo", "-i", "https://mirror", "--no-deps"] + + +def test_install_requirements_passes_extra_params_to_pip(monkeypatch): + calls = [] + monkeypatch.setattr(pkgmgr, "get_pip_index_args", lambda: ["-i", "https://mirror"]) + monkeypatch.setattr(pkgmgr, "pipmain", lambda params: calls.append(params)) + + pkgmgr.install_requirements("requirements.txt", ["--no-deps"]) + + assert calls == [ + [ + "install", + "-r", + "requirements.txt", + "-i", + "https://mirror", + "--no-deps", + ] + ] + + +def test_install_single_async_builds_pip_command_and_parses_output(monkeypatch): + class Proc: + returncode = 0 + + async def communicate(self): + return b"Downloading async.whl (2 kB)", b"" + + calls = [] + + async def fake_create_subprocess_exec(*cmd, stdout=None, stderr=None): + calls.append((cmd, stdout, stderr)) + return Proc() + + monkeypatch.setattr(pkgmgr, "get_pip_index_args", lambda: []) + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + + returncode, downloaded, output = asyncio.run(pkgmgr.install_single_async("demo")) + + assert returncode == 0 + assert downloaded == 2048 + assert "Downloading async.whl" in output + assert calls[0][0][-2:] == ("install", "demo") diff --git a/tests/runtime/io/handlers/test_control_handler.py b/tests/runtime/io/handlers/test_control_handler.py new file mode 100644 index 00000000..b221c02c --- /dev/null +++ b/tests/runtime/io/handlers/test_control_handler.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from langbot_plugin.entities.io.actions.enums import ( + CommonAction, + LangBotToRuntimeAction, +) +from langbot_plugin.runtime.io.handlers.control import ControlConnectionHandler + +from tests.helpers.protocol import ProtocolConnection, ProtocolSession + + +class Dumpable: + def __init__(self, payload): + self.payload = payload + + def model_dump(self, **kwargs): + return self.payload + + +class FakePlugin: + def __init__(self, author="tester", name="demo"): + self.manifest = SimpleNamespace( + metadata=SimpleNamespace(author=author, name=name) + ) + + def model_dump(self, **kwargs): + return {"manifest": {"author": self.manifest.metadata.author, "name": self.manifest.metadata.name}} + + +class FakePluginManager: + def __init__(self): + self.plugins = [FakePlugin()] + self.calls = [] + + async def get_plugin_readme(self, author, plugin_name, language): + self.calls.append(("get_plugin_readme", author, plugin_name, language)) + return b"# readme" + + async def get_plugin_assets_file(self, author, plugin_name, file_key): + self.calls.append(("get_plugin_assets_file", author, plugin_name, file_key)) + return b"asset", "text/plain" + + async def handle_page_api( + self, + plugin_author, + plugin_name, + page_id, + endpoint, + method, + body, + ): + self.calls.append( + ( + "handle_page_api", + plugin_author, + plugin_name, + page_id, + endpoint, + method, + body, + ) + ) + return {"data": {"ok": True}, "error": None} + + async def install_plugin(self, install_source, install_info): + self.calls.append(("install_plugin", install_source.value, install_info)) + yield {"current_action": "downloaded"} + yield {"current_action": "mounted"} + + async def restart_plugin(self, plugin_author, plugin_name): + self.calls.append(("restart_plugin", plugin_author, plugin_name)) + yield {"current_action": "stopped"} + + async def delete_plugin(self, plugin_author, plugin_name): + self.calls.append(("delete_plugin", plugin_author, plugin_name)) + yield {"current_action": "deleted"} + + async def list_tools(self, include_plugins=None): + self.calls.append(("list_tools", include_plugins)) + return [Dumpable({"name": "weather"})] + + async def call_tool( + self, + tool_name, + tool_parameters, + session, + query_id, + include_plugins=None, + ): + self.calls.append( + ( + "call_tool", + tool_name, + tool_parameters, + session, + query_id, + include_plugins, + ) + ) + return {"text": "sunny"} + + async def list_commands(self, include_plugins=None): + self.calls.append(("list_commands", include_plugins)) + return [Dumpable({"name": "start"})] + + async def list_knowledge_engines(self): + self.calls.append(("list_knowledge_engines",)) + return [{"name": "rag"}] + + async def rag_ingest_document(self, plugin_author, plugin_name, context_data): + self.calls.append( + ("rag_ingest_document", plugin_author, plugin_name, context_data) + ) + return {"document_id": "doc"} + + async def list_parsers(self): + self.calls.append(("list_parsers",)) + return [{"name": "parser"}] + + async def parse_document(self, plugin_author, plugin_name, context_data, file_bytes): + self.calls.append( + ("parse_document", plugin_author, plugin_name, context_data, file_bytes) + ) + return {"text": "parsed"} + + +def _handler(): + manager = FakePluginManager() + context = SimpleNamespace(plugin_mgr=manager, ws_debug_port=5401) + handler = ControlConnectionHandler(ProtocolConnection(), context) + return handler, manager + + +async def test_control_handler_ping_protocol_response(): + handler, _manager = _handler() + + async with ProtocolSession(handler) as session: + response = await session.request(CommonAction.PING.value, seq_id=10) + + assert response["seq_id"] == 10 + assert response["code"] == 0 + assert response["data"] == {"message": "pong"} + + +async def test_control_handler_lists_plugins_over_protocol(): + handler, _manager = _handler() + + async with ProtocolSession(handler) as session: + response = await session.request(LangBotToRuntimeAction.LIST_PLUGINS.value) + + assert response["code"] == 0 + assert response["data"] == { + "plugins": [{"manifest": {"author": "tester", "name": "demo"}}] + } + + +async def test_control_handler_get_plugin_info_returns_match_or_none(): + handler, _manager = _handler() + + async with ProtocolSession(handler) as session: + found = await session.request( + LangBotToRuntimeAction.GET_PLUGIN_INFO.value, + {"author": "tester", "plugin_name": "demo"}, + seq_id=1, + ) + missing = await session.request( + LangBotToRuntimeAction.GET_PLUGIN_INFO.value, + {"author": "tester", "plugin_name": "missing"}, + seq_id=2, + ) + + assert found["data"]["plugin"] == {"manifest": {"author": "tester", "name": "demo"}} + assert missing["data"]["plugin"] is None + + +async def test_control_handler_get_plugin_readme_sends_file_key(monkeypatch): + handler, manager = _handler() + + async def fake_send_file(file_bytes, extension): + assert file_bytes == b"# readme" + assert extension == "md" + return "readme-key" + + monkeypatch.setattr(handler, "send_file", fake_send_file) + + async with ProtocolSession(handler) as session: + response = await session.request( + LangBotToRuntimeAction.GET_PLUGIN_README.value, + { + "plugin_author": "tester", + "plugin_name": "demo", + "language": "zh", + }, + ) + + assert manager.calls == [("get_plugin_readme", "tester", "demo", "zh")] + assert response["data"] == {"readme_file_key": "readme-key"} + + +async def test_control_handler_get_plugin_assets_file_sends_file_key(monkeypatch): + handler, manager = _handler() + + async def fake_send_file(file_bytes, extension): + assert file_bytes == b"asset" + assert extension == "" + return "asset-key" + + monkeypatch.setattr(handler, "send_file", fake_send_file) + + async with ProtocolSession(handler) as session: + response = await session.request( + LangBotToRuntimeAction.GET_PLUGIN_ASSETS_FILE.value, + { + "plugin_author": "tester", + "plugin_name": "demo", + "file_path": "icon.svg", + }, + ) + + assert manager.calls == [ + ("get_plugin_assets_file", "tester", "demo", "icon.svg") + ] + assert response["data"] == { + "file_file_key": "asset-key", + "mime_type": "text/plain", + } + + +async def test_control_handler_page_api_validates_required_fields(): + handler, _manager = _handler() + + async with ProtocolSession(handler) as session: + response = await session.request( + LangBotToRuntimeAction.PAGE_API.value, + {"plugin_author": "tester", "plugin_name": "demo"}, + ) + + assert response["code"] == 0 + assert response["data"] == { + "data": None, + "error": "Missing required field: page_id", + } + + +async def test_control_handler_page_api_delegates_to_plugin_manager(): + handler, manager = _handler() + + async with ProtocolSession(handler) as session: + response = await session.request( + LangBotToRuntimeAction.PAGE_API.value, + { + "plugin_author": "tester", + "plugin_name": "demo", + "page_id": "settings", + "endpoint": "/save", + "method": "POST", + "body": {"enabled": True}, + }, + ) + + assert manager.calls == [ + ( + "handle_page_api", + "tester", + "demo", + "settings", + "/save", + "POST", + {"enabled": True}, + ) + ] + assert response["data"] == {"data": {"ok": True}, "error": None} + + +async def test_control_handler_install_plugin_streams_progress_and_reads_local_package( + monkeypatch, +): + handler, manager = _handler() + file_ops = [] + + async def fake_read_local_file(file_key): + file_ops.append(("read", file_key)) + return b"package" + + async def fake_delete_local_file(file_key): + file_ops.append(("delete", file_key)) + + monkeypatch.setattr(handler, "read_local_file", fake_read_local_file) + monkeypatch.setattr(handler, "delete_local_file", fake_delete_local_file) + + async with ProtocolSession(handler) as session: + responses = await session.request_messages( + LangBotToRuntimeAction.INSTALL_PLUGIN.value, + { + "install_source": "local", + "install_info": {"plugin_file_key": "pkg-key"}, + }, + count=4, + ) + + assert file_ops == [("read", "pkg-key"), ("delete", "pkg-key")] + assert manager.calls == [ + ( + "install_plugin", + "local", + {"plugin_file_key": "pkg-key", "plugin_file": b"package"}, + ) + ] + assert [response["chunk_status"] for response in responses] == [ + "continue", + "continue", + "continue", + "end", + ] + assert [response["data"] for response in responses] == [ + {"current_action": "downloaded"}, + {"current_action": "mounted"}, + {"current_action": "plugin installed"}, + {}, + ] + + +async def test_control_handler_parse_document_reads_transferred_file(monkeypatch): + handler, manager = _handler() + file_ops = [] + + async def fake_read_local_file(file_key): + file_ops.append(("read", file_key)) + return b"document" + + async def fake_delete_local_file(file_key): + file_ops.append(("delete", file_key)) + + monkeypatch.setattr(handler, "read_local_file", fake_read_local_file) + monkeypatch.setattr(handler, "delete_local_file", fake_delete_local_file) + + async with ProtocolSession(handler) as session: + response = await session.request( + LangBotToRuntimeAction.PARSE_DOCUMENT.value, + { + "plugin_author": "tester", + "plugin_name": "demo", + "context": {"file_key": "file-key", "mime_type": "text/plain"}, + }, + ) + + assert file_ops == [("read", "file-key"), ("delete", "file-key")] + assert manager.calls == [ + ( + "parse_document", + "tester", + "demo", + {"mime_type": "text/plain"}, + b"document", + ) + ] + assert response["data"] == {"text": "parsed"} + + +async def test_control_handler_lists_tools_and_commands_with_include_filter(): + handler, manager = _handler() + + async with ProtocolSession(handler) as session: + tools = await session.request( + LangBotToRuntimeAction.LIST_TOOLS.value, + {"include_plugins": ["tester/demo"]}, + seq_id=1, + ) + commands = await session.request( + LangBotToRuntimeAction.LIST_COMMANDS.value, + {"include_plugins": ["tester/demo"]}, + seq_id=2, + ) + + assert tools["data"] == {"tools": [{"name": "weather"}]} + assert commands["data"] == {"commands": [{"name": "start"}]} + assert manager.calls == [ + ("list_tools", ["tester/demo"]), + ("list_commands", ["tester/demo"]), + ] + + +async def test_control_handler_call_tool_delegates_session_and_query_context(): + handler, manager = _handler() + + async with ProtocolSession(handler) as session: + response = await session.request( + LangBotToRuntimeAction.CALL_TOOL.value, + { + "tool_name": "weather", + "tool_parameters": {"city": "Shanghai"}, + "session": {"id": "s"}, + "query_id": 7, + "include_plugins": ["tester/demo"], + }, + ) + + assert response["data"] == {"tool_response": {"text": "sunny"}} + assert manager.calls == [ + ( + "call_tool", + "weather", + {"city": "Shanghai"}, + {"id": "s"}, + 7, + ["tester/demo"], + ) + ] + + +async def test_control_handler_rag_and_parser_discovery_actions(): + handler, manager = _handler() + + async with ProtocolSession(handler) as session: + engines = await session.request( + LangBotToRuntimeAction.LIST_KNOWLEDGE_ENGINES.value, + seq_id=1, + ) + parsers = await session.request( + LangBotToRuntimeAction.LIST_PARSERS.value, + seq_id=2, + ) + + assert engines["data"] == {"engines": [{"name": "rag"}]} + assert parsers["data"] == {"parsers": [{"name": "parser"}]} + assert manager.calls == [("list_knowledge_engines",), ("list_parsers",)] + + +async def test_control_handler_rag_ingest_document_delegates_context(): + handler, manager = _handler() + + async with ProtocolSession(handler) as session: + response = await session.request( + LangBotToRuntimeAction.RAG_INGEST_DOCUMENT.value, + { + "plugin_author": "tester", + "plugin_name": "demo", + "context": {"document_id": "doc"}, + }, + ) + + assert response["data"] == {"document_id": "doc"} + assert manager.calls == [ + ( + "rag_ingest_document", + "tester", + "demo", + {"document_id": "doc"}, + ) + ] diff --git a/tests/runtime/io/handlers/test_import_contracts.py b/tests/runtime/io/handlers/test_import_contracts.py new file mode 100644 index 00000000..7166cd43 --- /dev/null +++ b/tests/runtime/io/handlers/test_import_contracts.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import subprocess +import sys + +import pytest + + +@pytest.mark.xfail( + strict=True, + reason="#62 PluginConnectionHandler direct import fails due to circular import", +) +def test_plugin_connection_handler_should_be_directly_importable(): + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "from langbot_plugin.runtime.io.handlers.plugin " + "import PluginConnectionHandler\n" + "print(PluginConnectionHandler.__name__)\n" + ), + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "PluginConnectionHandler" diff --git a/tests/runtime/io/handlers/test_plugin_handler.py b/tests/runtime/io/handlers/test_plugin_handler.py new file mode 100644 index 00000000..17b68075 --- /dev/null +++ b/tests/runtime/io/handlers/test_plugin_handler.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from langbot_plugin.entities.io.actions.enums import ( + PluginToRuntimeAction, + RuntimeToLangBotAction, +) +import langbot_plugin.runtime.plugin.container # noqa: F401 +from langbot_plugin.runtime.io.handlers import plugin as plugin_handler_module +from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler + +from tests.helpers.protocol import ProtocolConnection, ProtocolSession + + +class FakeManifest: + def __init__(self, author="tester", name="demo"): + self.metadata = SimpleNamespace(author=author, name=name) + + def model_dump(self, **kwargs): + return {"metadata": {"author": self.metadata.author, "name": self.metadata.name}} + + +class FakePluginContainer: + def __init__(self, runtime_handler=None): + self._runtime_plugin_handler = runtime_handler + self.manifest = FakeManifest() + + def model_dump(self, **kwargs): + return {"manifest": self.manifest.model_dump()} + + +class FakeControlHandler: + def __init__(self): + self.calls = [] + self.results = {} + + async def call_action(self, action, data, timeout=15.0): + self.calls.append((action, data, timeout)) + return self.results.get(action, {"ok": True}) + + +class FakePluginManager: + def __init__(self): + self.plugins = [] + self.calls = [] + self.tools = [] + self.commands = [] + + async def register_plugin(self, handler, plugin_container, debug_plugin): + self.calls.append(("register_plugin", handler, plugin_container, debug_plugin)) + + async def remove_plugin_container(self, plugin_container): + self.calls.append(("remove_plugin_container", plugin_container)) + + async def list_tools(self): + self.calls.append(("list_tools",)) + return self.tools + + async def call_tool(self, tool_name, tool_parameters, session, query_id): + self.calls.append( + ("call_tool", tool_name, tool_parameters, session, query_id) + ) + return {"text": "tool response"} + + async def list_commands(self): + self.calls.append(("list_commands",)) + return self.commands + + +class FakeTool: + def __init__(self, name): + self.metadata = SimpleNamespace(name=name) + + def to_plain_dict(self): + return {"name": self.metadata.name} + + +class Dumpable: + def __init__(self, payload): + self.payload = payload + + def model_dump(self, **kwargs): + return self.payload + + +def _handler(debug_plugin=False): + control_handler = FakeControlHandler() + manager = FakePluginManager() + context = SimpleNamespace(control_handler=control_handler, plugin_mgr=manager) + handler = PluginConnectionHandler( + ProtocolConnection(), + context, + debug_plugin=debug_plugin, + ) + return handler, manager, control_handler + + +async def test_plugin_handler_registers_plugin_when_debug_key_matches(monkeypatch): + handler, manager, _control = _handler(debug_plugin=True) + monkeypatch.setattr(plugin_handler_module.runtime_settings, "plugin_debug_key", "key") + + async with ProtocolSession(handler) as session: + response = await session.request( + PluginToRuntimeAction.REGISTER_PLUGIN.value, + {"plugin_container": {"id": "plugin"}, "plugin_debug_key": "key"}, + ) + + assert response["code"] == 0 + assert manager.calls == [ + ("register_plugin", handler, {"id": "plugin"}, True) + ] + + +async def test_plugin_handler_rejects_plugin_with_invalid_debug_key(monkeypatch): + handler, manager, _control = _handler(debug_plugin=True) + monkeypatch.setattr(plugin_handler_module.runtime_settings, "plugin_debug_key", "key") + + async with ProtocolSession(handler) as session: + response = await session.request( + PluginToRuntimeAction.REGISTER_PLUGIN.value, + {"plugin_container": {"id": "plugin"}, "plugin_debug_key": "wrong"}, + ) + + assert response["code"] == 1 + assert response["message"] == "Plugin debug key verification failed" + assert manager.calls == [] + + +async def test_plugin_handler_prod_registration_disables_debug_mode(monkeypatch): + handler, manager, _control = _handler(debug_plugin=True) + monkeypatch.setattr(plugin_handler_module.runtime_settings, "plugin_debug_key", "") + + async with ProtocolSession(handler) as session: + response = await session.request( + PluginToRuntimeAction.REGISTER_PLUGIN.value, + {"plugin_container": {"id": "plugin"}, "prod_mode": True}, + ) + + assert response["code"] == 0 + assert handler.debug_plugin is False + assert manager.calls == [ + ("register_plugin", handler, {"id": "plugin"}, False) + ] + + +async def test_plugin_handler_forwards_invoke_llm_with_validated_timeout(): + handler, _manager, control = _handler() + control.results[PluginToRuntimeAction.INVOKE_LLM] = {"message": "ok"} + + async with ProtocolSession(handler) as session: + response = await session.request( + PluginToRuntimeAction.INVOKE_LLM.value, + {"messages": [], "timeout": -1}, + ) + + assert response["data"] == {"message": "ok"} + assert control.calls == [ + (PluginToRuntimeAction.INVOKE_LLM, {"messages": []}, 120.0) + ] + + +async def test_plugin_handler_adds_plugin_owner_for_binary_storage(): + handler, manager, control = _handler() + manager.plugins = [FakePluginContainer(runtime_handler=handler)] + + async with ProtocolSession(handler) as session: + response = await session.request( + PluginToRuntimeAction.SET_PLUGIN_STORAGE.value, + {"key": "cache", "value_base64": "dmFsdWU="}, + ) + + assert response["code"] == 0 + assert control.calls == [ + ( + RuntimeToLangBotAction.SET_BINARY_STORAGE, + { + "key": "cache", + "value_base64": "dmFsdWU=", + "owner_type": "plugin", + "owner": "tester/demo", + }, + 15.0, + ) + ] + + +async def test_plugin_handler_workspace_storage_uses_default_workspace_owner(): + handler, _manager, control = _handler() + + async with ProtocolSession(handler) as session: + response = await session.request( + PluginToRuntimeAction.GET_WORKSPACE_STORAGE.value, + {"key": "shared"}, + ) + + assert response["code"] == 0 + assert control.calls == [ + ( + RuntimeToLangBotAction.GET_BINARY_STORAGE, + {"key": "shared", "owner_type": "workspace", "owner": "default"}, + 15.0, + ) + ] + + +async def test_plugin_handler_forwards_config_file_requests_to_langbot(): + handler, _manager, control = _handler() + control.results[RuntimeToLangBotAction.GET_CONFIG_FILE] = {"file_base64": "Y29uZmln"} + + async with ProtocolSession(handler) as session: + response = await session.request( + PluginToRuntimeAction.GET_CONFIG_FILE.value, + {"file_key": "settings.yaml"}, + ) + + assert response["data"] == {"file_base64": "Y29uZmln"} + assert control.calls == [ + ( + RuntimeToLangBotAction.GET_CONFIG_FILE, + {"file_key": "settings.yaml"}, + 15.0, + ) + ] + + +async def test_plugin_handler_get_knowledge_file_stream_repackages_file(monkeypatch): + handler, _manager, control = _handler() + file_ops = [] + control.results[PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM] = { + "file_key": "host-file" + } + + async def fake_read_local_file(file_key): + file_ops.append(("read", file_key)) + return b"file-bytes" + + async def fake_delete_local_file(file_key): + file_ops.append(("delete", file_key)) + + async def fake_send_file(file_bytes, extension): + file_ops.append(("send", file_bytes, extension)) + return "plugin-file" + + monkeypatch.setattr(handler, "read_local_file", fake_read_local_file) + monkeypatch.setattr(handler, "delete_local_file", fake_delete_local_file) + monkeypatch.setattr(handler, "send_file", fake_send_file) + + async with ProtocolSession(handler) as session: + response = await session.request( + PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM.value, + {"storage_path": "kb/doc"}, + ) + + assert file_ops == [ + ("read", "host-file"), + ("delete", "host-file"), + ("send", b"file-bytes", ""), + ] + assert response["data"] == {"file_key": "plugin-file"} + + +async def test_plugin_handler_lists_tools_and_reports_missing_tool_detail(): + handler, manager, _control = _handler() + manager.tools = [FakeTool("weather")] + + async with ProtocolSession(handler) as session: + listed = await session.request(PluginToRuntimeAction.LIST_TOOLS.value, seq_id=1) + missing = await session.request( + PluginToRuntimeAction.GET_TOOL_DETAIL.value, + {"tool_name": "missing"}, + seq_id=2, + ) + + assert listed["data"] == {"tools": [{"name": "weather"}]} + assert missing["code"] == 1 + assert missing["message"] == "Tool not found: missing" + + +async def test_plugin_handler_calls_registered_runtime_tool(): + handler, manager, _control = _handler() + + async with ProtocolSession(handler) as session: + response = await session.request( + PluginToRuntimeAction.CALL_TOOL.value, + { + "tool_name": "weather", + "tool_parameters": {"city": "Shanghai"}, + "session": {"id": "s"}, + "query_id": 7, + }, + ) + + assert response["data"] == {"tool_response": {"text": "tool response"}} + assert manager.calls == [ + ( + "call_tool", + "weather", + {"city": "Shanghai"}, + {"id": "s"}, + 7, + ) + ] + + +async def test_plugin_handler_lists_plugin_manifests(): + handler, manager, _control = _handler() + manager.plugins = [FakePluginContainer()] + + async with ProtocolSession(handler) as session: + response = await session.request( + PluginToRuntimeAction.LIST_PLUGINS_MANIFEST.value + ) + + assert response["data"] == { + "plugins": [{"metadata": {"author": "tester", "name": "demo"}}] + } diff --git a/tests/runtime/io/test_connections.py b/tests/runtime/io/test_connections.py new file mode 100644 index 00000000..1552f910 --- /dev/null +++ b/tests/runtime/io/test_connections.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import json + +from langbot_plugin.runtime.io.connections.stdio import StdioConnection +from langbot_plugin.runtime.io.connections.ws import WebSocketConnection + + +class FakeStreamReader: + def __init__(self, lines: list[bytes]): + self.lines = lines + + async def readline(self): + return self.lines.pop(0) + + +class FakeStreamWriter: + def __init__(self): + self.writes: list[bytes] = [] + self.closed = False + + def write(self, data: bytes): + self.writes.append(data) + + async def drain(self): + pass + + def close(self): + self.closed = True + + +class AsyncChunkIterator: + def __init__(self, chunks: list[str]): + self.chunks = chunks + + def __aiter__(self): + return self + + async def __anext__(self): + if not self.chunks: + raise StopAsyncIteration + return self.chunks.pop(0) + + +class FakeWebSocket: + def __init__(self, receive_batches: list[list[str]] | None = None): + self.sent: list[tuple[str, bool]] = [] + self.receive_batches = receive_batches or [] + self.closed = False + + async def send(self, data: str, text: bool = False): + self.sent.append((data, text)) + + def recv_streaming(self, decode: bool = False): + return AsyncChunkIterator(self.receive_batches.pop(0)) + + async def close(self): + self.closed = True + + +async def test_stdio_connection_sends_small_message_with_newline(): + writer = FakeStreamWriter() + connection = StdioConnection(FakeStreamReader([]), writer) + + await connection.send('{"ok": true}') + + assert writer.writes == [b'{"ok": true}\n'] + + +async def test_stdio_connection_sends_large_message_as_json_chunks(): + writer = FakeStreamWriter() + connection = StdioConnection(FakeStreamReader([]), writer, chunk_size=4) + + await connection.send("abcdefghi") + + payloads = [json.loads(line.decode()) for line in writer.writes] + assert payloads[0] == {"type": "chunk_start", "total_size": 9} + assert payloads[1:4] == [ + {"type": "chunk_data", "data": "abcd", "offset": 0}, + {"type": "chunk_data", "data": "efgh", "offset": 4}, + {"type": "chunk_data", "data": "i", "offset": 8}, + ] + assert payloads[4] == {"type": "chunk_end"} + + +async def test_stdio_connection_receives_json_message_after_blank_line(): + reader = FakeStreamReader([b"\n", b'{"type": "event", "id": 1}\n']) + connection = StdioConnection(reader, FakeStreamWriter()) + + assert await connection.receive() == '{"type": "event", "id": 1}' + + +async def test_stdio_connection_reassembles_chunked_message(): + chunk_lines = [ + {"type": "chunk_start", "total_size": 11}, + {"type": "chunk_data", "data": "hello ", "offset": 0}, + {"type": "chunk_data", "data": "world", "offset": 6}, + {"type": "chunk_end"}, + ] + reader = FakeStreamReader( + [json.dumps(chunk).encode() + b"\n" for chunk in chunk_lines] + ) + connection = StdioConnection(reader, FakeStreamWriter()) + + assert await connection.receive() == "hello world" + + +async def test_stdio_connection_close_closes_writer(): + writer = FakeStreamWriter() + connection = StdioConnection(FakeStreamReader([]), writer) + + await connection.close() + + assert writer.closed is True + + +async def test_websocket_connection_sends_small_message_directly(): + websocket = FakeWebSocket() + connection = WebSocketConnection(websocket) + + await connection.send('{"ok": true}') + + assert websocket.sent == [('{"ok": true}', True)] + + +async def test_websocket_connection_sends_large_message_in_chunks(): + websocket = FakeWebSocket() + connection = WebSocketConnection(websocket, chunk_size=4) + + await connection.send("abcdefghi") + + assert websocket.sent == [("abcd", True), ("efgh", True), ("i", True)] + + +async def test_websocket_connection_receives_streamed_json_message(): + websocket = FakeWebSocket(receive_batches=[['{"ok": ', "true}"]]) + connection = WebSocketConnection(websocket) + + assert await connection.receive() == '{"ok": true}' + + +async def test_websocket_connection_close_closes_socket(): + websocket = FakeWebSocket() + connection = WebSocketConnection(websocket) + + await connection.close() + + assert websocket.closed is True diff --git a/tests/runtime/io/test_controllers.py b/tests/runtime/io/test_controllers.py new file mode 100644 index 00000000..4886fd3e --- /dev/null +++ b/tests/runtime/io/test_controllers.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import pytest + +from langbot_plugin.runtime.io.connections.stdio import StdioConnection +from langbot_plugin.runtime.io.connections.ws import WebSocketConnection +from langbot_plugin.runtime.io.controllers.stdio import client as stdio_client +from langbot_plugin.runtime.io.controllers.stdio import server as stdio_server +from langbot_plugin.runtime.io.controllers.ws import client as ws_client +from langbot_plugin.runtime.io.controllers.ws import server as ws_server + + +class FakeProcess: + def __init__(self, stdin=object(), stdout=object()): + self.stdin = stdin + self.stdout = stdout + + +class FakeWebSocket: + remote_address = ("127.0.0.1", 12345) + + +class FakeWebSocketConnectContext: + def __init__(self, websocket): + self.websocket = websocket + + async def __aenter__(self): + return self.websocket + + async def __aexit__(self, exc_type, exc, traceback): + return False + + +class FakeServer: + def __init__(self): + self.waited = False + + async def wait_closed(self): + self.waited = True + + +async def test_stdio_client_controller_creates_process_connection(monkeypatch): + process = FakeProcess() + captured = {} + + async def fake_create_subprocess_exec(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return process + + async def callback(connection): + captured["connection"] = connection + + monkeypatch.setattr( + stdio_client.asyncio, + "create_subprocess_exec", + fake_create_subprocess_exec, + ) + controller = stdio_client.StdioClientController( + command="python", + args=["plugin.py"], + env={"TOKEN": "secret"}, + working_dir="/tmp/plugin", + ) + + await controller.run(callback) + + assert captured["args"][:2] == ("python", "plugin.py") + assert captured["kwargs"]["env"] == {"TOKEN": "secret"} + assert captured["kwargs"]["cwd"] == "/tmp/plugin" + assert isinstance(captured["connection"], StdioConnection) + assert captured["connection"].process is process + + +async def test_stdio_client_controller_rejects_missing_pipes(monkeypatch): + async def fake_create_subprocess_exec(*args, **kwargs): + return FakeProcess(stdin=None, stdout=object()) + + monkeypatch.setattr( + stdio_client.asyncio, + "create_subprocess_exec", + fake_create_subprocess_exec, + ) + controller = stdio_client.StdioClientController("python", [], {}, ".") + + with pytest.raises(RuntimeError, match="Failed to create subprocess pipes"): + await controller.run(lambda connection: None) + + +async def test_stdio_server_controller_wraps_standard_streams(monkeypatch): + captured = {} + + async def fake_connect_stdin_stdout(): + return object(), object() + + async def callback(connection): + captured["connection"] = connection + + monkeypatch.setattr( + stdio_server, + "connect_stdin_stdout", + fake_connect_stdin_stdout, + ) + + await stdio_server.StdioServerController().run(callback) + + assert isinstance(captured["connection"], StdioConnection) + + +async def test_websocket_client_controller_invokes_connection_callback(monkeypatch): + captured = {} + websocket = FakeWebSocket() + + def fake_connect(url, open_timeout): + captured["url"] = url + captured["open_timeout"] = open_timeout + return FakeWebSocketConnectContext(websocket) + + async def callback(connection): + captured["connection"] = connection + + async def on_failed(controller, exc): + captured["failure"] = (controller, exc) + + monkeypatch.setattr(ws_client.websockets, "connect", fake_connect) + controller = ws_client.WebSocketClientController("ws://localhost:9000", on_failed) + + await controller.run(callback) + + assert captured["url"] == "ws://localhost:9000" + assert captured["open_timeout"] == 10 + assert isinstance(captured["connection"], WebSocketConnection) + assert "failure" not in captured + + +async def test_websocket_client_controller_reports_connection_failure(monkeypatch): + captured = {} + error = OSError("network down") + + def fake_connect(url, open_timeout): + raise error + + async def callback(connection): + captured["connection"] = connection + + async def on_failed(controller, exc): + captured["failure"] = (controller, exc) + + monkeypatch.setattr(ws_client.websockets, "connect", fake_connect) + controller = ws_client.WebSocketClientController("ws://localhost:9000", on_failed) + + await controller.run(callback) + + assert captured["failure"] == (controller, error) + assert "connection" not in captured + + +async def test_websocket_server_controller_run_waits_for_server(monkeypatch): + fake_server = FakeServer() + captured = {} + + async def fake_serve(handler, host, port): + captured["handler"] = handler + captured["host"] = host + captured["port"] = port + return fake_server + + async def callback(connection): + captured["connection"] = connection + + monkeypatch.setattr(ws_server.websockets, "serve", fake_serve) + controller = ws_server.WebSocketServerController(port=9000) + + await controller.run(callback) + + assert captured["handler"] == controller.handle_connection + assert captured["host"] == "0.0.0.0" + assert captured["port"] == 9000 + assert fake_server.waited is True + + +async def test_websocket_server_controller_wraps_new_connections(): + captured = {} + controller = ws_server.WebSocketServerController(port=9000) + + async def callback(connection): + captured["connection"] = connection + + controller._new_connection_callback = callback + + await controller.handle_connection(FakeWebSocket()) + + assert isinstance(captured["connection"], WebSocketConnection) diff --git a/tests/runtime/io/test_handler.py b/tests/runtime/io/test_handler.py new file mode 100644 index 00000000..c9c865d8 --- /dev/null +++ b/tests/runtime/io/test_handler.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import asyncio +import json + +import pytest + +from langbot_plugin.entities.io.actions.enums import ActionType, CommonAction +from langbot_plugin.entities.io.errors import ( + ActionCallError, + ActionCallTimeoutError, + ConnectionClosedError, +) +from langbot_plugin.entities.io.resp import ActionResponse, ChunkStatus +from langbot_plugin.runtime.io.connection import Connection +from langbot_plugin.runtime.io.handler import Handler + + +class SampleAction(ActionType): + ECHO = "echo" + STREAM = "stream" + + +class QueueConnection(Connection): + def __init__(self): + self.incoming: asyncio.Queue[str | BaseException] = asyncio.Queue() + self.sent: list[str] = [] + self.sent_event = asyncio.Event() + self.closed = False + + async def send(self, message: str) -> None: + self.sent.append(message) + self.sent_event.set() + + async def receive(self) -> str: + message = await self.incoming.get() + if isinstance(message, BaseException): + raise message + return message + + async def close(self) -> None: + self.closed = True + + +async def _wait_for_sent(conn: QueueConnection, count: int = 1) -> list[dict]: + for _ in range(50): + if len(conn.sent) >= count: + return [json.loads(message) for message in conn.sent] + await asyncio.sleep(0.01) + raise AssertionError(f"timed out waiting for {count} sent messages") + + +@pytest.mark.asyncio +async def test_call_action_sends_request_and_returns_response_data(): + conn = QueueConnection() + handler = Handler(conn) + + task = asyncio.create_task( + handler.call_action(SampleAction.ECHO, {"message": "hello"}, timeout=1) + ) + [request] = await _wait_for_sent(conn) + assert request["action"] == "echo" + assert request["data"] == {"message": "hello"} + + handler.resp_waiters[request["seq_id"]].set_result( + ActionResponse(seq_id=request["seq_id"], code=0, message="ok", data={"ok": True}) + ) + + assert await task == {"ok": True} + assert request["seq_id"] not in handler.resp_waiters + + +@pytest.mark.asyncio +async def test_call_action_timeout_cleans_waiter(): + conn = QueueConnection() + handler = Handler(conn) + + with pytest.raises(ActionCallTimeoutError, match="Action echo call timed out"): + await handler.call_action(SampleAction.ECHO, {}, timeout=0.01) + + assert handler.resp_waiters == {} + + +@pytest.mark.xfail( + strict=True, + reason="#58 call_action wraps ActionCallError in a second ActionCallError", +) +@pytest.mark.asyncio +async def test_call_action_error_response_should_preserve_peer_message(): + conn = QueueConnection() + handler = Handler(conn) + task = asyncio.create_task(handler.call_action(SampleAction.ECHO, {}, timeout=1)) + [request] = await _wait_for_sent(conn) + + handler.resp_waiters[request["seq_id"]].set_result( + ActionResponse(seq_id=request["seq_id"], code=1, message="peer failed", data={}) + ) + + with pytest.raises(ActionCallError, match="^peer failed$"): + await task + + +@pytest.mark.asyncio +async def test_call_action_generator_yields_chunks_until_end(): + conn = QueueConnection() + handler = Handler(conn) + chunks: list[dict] = [] + + async def consume(): + async for chunk in handler.call_action_generator( + SampleAction.STREAM, {}, timeout=1 + ): + chunks.append(chunk) + + task = asyncio.create_task(consume()) + [request] = await _wait_for_sent(conn) + queue = handler.resp_queues[request["seq_id"]] + await queue.put( + ActionResponse( + seq_id=request["seq_id"], + code=0, + message="ok", + data={"part": 1}, + chunk_status=ChunkStatus.CONTINUE, + ) + ) + await queue.put( + ActionResponse( + seq_id=request["seq_id"], + code=0, + message="ok", + data={}, + chunk_status=ChunkStatus.END, + ) + ) + + await task + assert chunks == [{"part": 1}] + assert handler.resp_queues == {} + + +@pytest.mark.asyncio +async def test_run_dispatches_registered_action_and_sends_response(): + conn = QueueConnection() + handler = Handler(conn) + + @handler.action(SampleAction.ECHO) + async def echo(data): + return ActionResponse.success({"echo": data["message"]}) + + task = asyncio.create_task(handler.run()) + await conn.incoming.put( + json.dumps({"seq_id": 7, "action": "echo", "data": {"message": "hi"}}) + ) + [response] = await _wait_for_sent(conn) + await conn.incoming.put(ConnectionClosedError("closed")) + await task + + assert response["seq_id"] == 7 + assert response["code"] == 0 + assert response["data"] == {"echo": "hi"} + + +@pytest.mark.asyncio +async def test_run_sends_error_response_for_unknown_action(): + conn = QueueConnection() + handler = Handler(conn) + + task = asyncio.create_task(handler.run()) + await conn.incoming.put(json.dumps({"seq_id": 9, "action": "missing", "data": {}})) + [response] = await _wait_for_sent(conn) + await conn.incoming.put(ConnectionClosedError("closed")) + await task + + assert response["seq_id"] == 9 + assert response["code"] == 1 + assert "Action missing not found" in response["message"] + + +@pytest.mark.asyncio +async def test_run_handles_streaming_action_response(): + conn = QueueConnection() + handler = Handler(conn) + + @handler.action(SampleAction.STREAM) + async def stream(_data): + yield ActionResponse.success({"part": 1}) + yield ActionResponse.success({"part": 2}) + + task = asyncio.create_task(handler.run()) + await conn.incoming.put(json.dumps({"seq_id": 3, "action": "stream", "data": {}})) + responses = await _wait_for_sent(conn, count=3) + await conn.incoming.put(ConnectionClosedError("closed")) + await task + + assert [response["chunk_status"] for response in responses] == [ + "continue", + "continue", + "end", + ] + assert [response["data"] for response in responses] == [ + {"part": 1}, + {"part": 2}, + {}, + ] + + +@pytest.mark.asyncio +async def test_send_file_calls_file_chunk_action_for_each_chunk(monkeypatch): + conn = QueueConnection() + handler = Handler(conn) + calls: list[dict] = [] + + async def fake_call_action(action, data, timeout=15.0): + calls.append({"action": action, "data": data, "timeout": timeout}) + return {} + + monkeypatch.setattr(handler, "call_action", fake_call_action) + file_key = await handler.send_file(b"abc", "txt") + + assert file_key.endswith(".txt") + assert calls[0]["action"] is CommonAction.FILE_CHUNK + assert calls[0]["data"]["file_length"] == 3 + assert calls[0]["data"]["chunk_amount"] == 1 + + +def test_handler_file_storage_dir_is_created_for_instances(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + Handler(QueueConnection()) + + assert (tmp_path / "data" / "temp" / "lbp").is_dir() diff --git a/tests/runtime/plugin/test_container.py b/tests/runtime/plugin/test_container.py new file mode 100644 index 00000000..a5e97d89 --- /dev/null +++ b/tests/runtime/plugin/test_container.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import pytest + +from langbot_plugin.api.definition.components.base import NoneComponent +from langbot_plugin.api.definition.components.manifest import ComponentManifest +from langbot_plugin.api.definition.plugin import NonePlugin +from langbot_plugin.runtime.plugin.container import ( + ComponentContainer, + PluginContainer, + RuntimeContainerStatus, +) + + +def _manifest(kind: str = "Plugin", name: str = "demo") -> ComponentManifest: + return ComponentManifest( + owner="tester", + manifest={ + "apiVersion": "v1", + "kind": kind, + "metadata": { + "name": name, + "label": {"en_US": name.title()}, + "author": "tester", + "version": "1.0.0", + }, + "spec": {"components": {}}, + }, + rel_path="manifest.yaml", + ) + + +def test_component_container_dump_excludes_runtime_instance(): + container = ComponentContainer( + manifest=_manifest(kind="Tool", name="weather"), + component_instance=NoneComponent(), + component_config={"enabled": True}, + ) + + dumped = container.model_dump() + + assert dumped["component_instance"] is None + assert dumped["component_config"] == {"enabled": True} + assert dumped["manifest"]["manifest"]["kind"] == "Tool" + + +def test_component_container_roundtrip_uses_none_component_placeholder(): + original = ComponentContainer( + manifest=_manifest(kind="Command", name="hello"), + component_instance=NoneComponent(), + component_config={"prefix": "/"}, + ) + + restored = ComponentContainer.from_dict(original.model_dump()) + + assert isinstance(restored.component_instance, NoneComponent) + assert restored.component_config == {"prefix": "/"} + assert restored.manifest.kind == "Command" + + +def test_plugin_container_dump_excludes_plugin_instance_and_serializes_status(): + component = ComponentContainer( + manifest=_manifest(kind="Tool", name="weather"), + component_instance=NoneComponent(), + component_config={}, + ) + container = PluginContainer( + debug=True, + install_source="local", + install_info={"path": "."}, + manifest=_manifest(), + plugin_instance=NonePlugin(), + enabled=True, + priority=10, + plugin_config={"token": "x"}, + status=RuntimeContainerStatus.INITIALIZED, + components=[component], + ) + + dumped = container.model_dump() + + assert dumped["plugin_instance"] is None + assert dumped["status"] == "initialized" + assert dumped["components"][0]["component_instance"] is None + + +def test_plugin_container_roundtrip_uses_none_plugin_placeholder(): + container = PluginContainer( + debug=False, + install_source="marketplace", + install_info={"id": "tester/demo"}, + manifest=_manifest(), + plugin_instance=NonePlugin(), + enabled=False, + priority=0, + plugin_config={}, + status=RuntimeContainerStatus.MOUNTED, + components=[], + ) + + restored = PluginContainer.from_dict(container.model_dump()) + + assert isinstance(restored.plugin_instance, NonePlugin) + assert restored.status is RuntimeContainerStatus.MOUNTED + assert restored.enabled is False + assert restored.components == [] + + +@pytest.mark.xfail( + strict=True, + reason="#61 PluginContainer.from_dict drops install_source/install_info fields", +) +def test_plugin_container_roundtrip_should_preserve_install_metadata(): + container = PluginContainer( + debug=False, + install_source="marketplace", + install_info={"id": "tester/demo"}, + manifest=_manifest(), + plugin_instance=NonePlugin(), + enabled=True, + priority=0, + plugin_config={}, + status=RuntimeContainerStatus.MOUNTED, + components=[], + ) + + restored = PluginContainer.from_dict(container.model_dump()) + + assert restored.install_source == "marketplace" + assert restored.install_info == {"id": "tester/demo"} diff --git a/tests/runtime/plugin/test_manager.py b/tests/runtime/plugin/test_manager.py new file mode 100644 index 00000000..4dfec4da --- /dev/null +++ b/tests/runtime/plugin/test_manager.py @@ -0,0 +1,510 @@ +from __future__ import annotations + +import asyncio +import io +import zipfile +from types import SimpleNamespace +from typing import Any + +import pytest + +from langbot_plugin.api.definition.components.base import NoneComponent +from langbot_plugin.api.definition.components.manifest import ComponentManifest +from langbot_plugin.api.definition.plugin import NonePlugin +from langbot_plugin.api.entities.builtin.command.context import CommandReturn +from langbot_plugin.api.entities.context import EventContext +from langbot_plugin.api.entities.events import PersonCommandSent +from langbot_plugin.runtime.plugin.container import ( + ComponentContainer, + PluginContainer, + RuntimeContainerStatus, +) +from langbot_plugin.runtime.plugin.mgr import PluginInstallSource, PluginManager + + +def _manifest( + kind: str = "Plugin", + name: str = "demo", + author: str = "tester", + spec: dict[str, Any] | None = None, +) -> ComponentManifest: + return ComponentManifest( + owner=author, + rel_path=f"{name}.yaml", + manifest={ + "apiVersion": "v1", + "kind": kind, + "metadata": { + "name": name, + "label": {"en_US": name.title()}, + "author": author, + "version": "1.0.0", + }, + "spec": spec or {}, + }, + ) + + +def _component(kind: str, name: str, spec: dict[str, Any] | None = None): + return ComponentContainer( + manifest=_manifest(kind=kind, name=name, spec=spec), + component_instance=NoneComponent(), + component_config={}, + ) + + +def _plugin( + name: str = "demo", + author: str = "tester", + *, + components: list[ComponentContainer] | None = None, + status: RuntimeContainerStatus = RuntimeContainerStatus.INITIALIZED, + enabled: bool = True, + debug: bool = False, +) -> PluginContainer: + return PluginContainer( + debug=debug, + install_source="local", + install_info={}, + manifest=_manifest(name=name, author=author), + plugin_instance=NonePlugin(), + enabled=enabled, + priority=0, + plugin_config={}, + status=status, + components=components or [], + ) + + +def _manager() -> PluginManager: + manager = PluginManager(SimpleNamespace(ws_debug_port=18080)) + manager.plugins = [] + manager.plugin_handlers = [] + manager.plugin_run_tasks = [] + return manager + + +def _plugin_zip(author: str = "tester", name: str = "demo", version: str = "1.0.0"): + manifest = f""" +apiVersion: v1 +kind: Plugin +metadata: + name: {name} + label: + en_US: Demo + author: {author} + version: {version} +spec: {{}} +""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("manifest.yaml", manifest) + archive.writestr("main.py", "") + return buffer.getvalue() + + +class FakeControlHandler: + def __init__(self): + self.calls: list[tuple[Any, dict[str, Any]]] = [] + + async def call_action(self, action, payload): + self.calls.append((action, payload)) + return { + "enabled": True, + "priority": 7, + "plugin_config": {"api_key": "secret"}, + "install_source": PluginInstallSource.MARKETPLACE.value, + "install_info": {"plugin_version": "1.0.0"}, + } + + +class FakeConnection: + closed = False + + async def close(self): + self.closed = True + + +class FakeHandler: + def __init__(self, plugin: PluginContainer): + self.plugin = plugin + self.debug_plugin = False + self.conn = FakeConnection() + self.stdio_process = None + self.initialized_with = None + self.shutdown_calls = 0 + self.files = { + "icon-key": b"", + "readme-key": b"# Demo", + "asset-key": b"asset-bytes", + } + + async def initialize_plugin(self, plugin_settings): + self.initialized_with = plugin_settings + + async def get_plugin_container(self): + refreshed = self.plugin.model_dump() + refreshed["status"] = RuntimeContainerStatus.INITIALIZED.value + return refreshed + + async def shutdown_plugin(self): + self.shutdown_calls += 1 + + async def emit_event(self, event_context): + return {"emitted": True, "event_context": event_context} + + async def call_tool(self, tool_name, tool_parameters, session, query_id): + return { + "tool_response": { + "tool_name": tool_name, + "params": tool_parameters, + "query_id": query_id, + } + } + + async def execute_command(self, command_context): + yield {"command_response": {"text": command_context["command"]}} + + async def get_plugin_icon(self): + return {"plugin_icon_file_key": "icon-key", "mime_type": "image/svg+xml"} + + async def get_plugin_readme(self, language="en"): + return {"plugin_readme_file_key": "readme-key", "language": language} + + async def get_plugin_assets_file(self, file_key): + if file_key == "missing": + return {"file_file_key": "", "mime_type": ""} + return {"file_file_key": "asset-key", "mime_type": "text/plain"} + + async def read_local_file(self, file_key): + return self.files[file_key] + + async def delete_local_file(self, file_key): + del self.files[file_key] + + async def call_page_api(self, page_id, endpoint, method, body): + return { + "data": { + "page_id": page_id, + "endpoint": endpoint, + "method": method, + "body": body, + }, + "error": None, + } + + async def get_rag_capabilities(self): + return {"capabilities": ["ingest", "retrieve"]} + + async def rag_ingest_document(self, context_data): + return {"ingested": context_data["document_id"]} + + async def rag_delete_document(self, kb_id, document_id): + return {"deleted": [kb_id, document_id]} + + async def rag_on_kb_create(self, kb_id, config): + return {"created": kb_id, "config": config} + + async def rag_on_kb_delete(self, kb_id): + return {"deleted_kb": kb_id} + + async def parse_document(self, context_data, file_bytes): + return {"context": context_data, "text": file_bytes.decode()} + + +@pytest.mark.xfail( + strict=True, + reason="#63 PluginManager instances share plugins/plugin_handlers class lists", +) +def test_plugin_manager_instances_should_not_share_plugin_state(): + PluginManager.plugins = [] + first = PluginManager(SimpleNamespace()) + second = PluginManager(SimpleNamespace()) + + first.plugins.append(_plugin()) + + assert second.plugins == [] + + +def test_find_plugin_and_component_lists_respect_include_filters(): + manager = _manager() + tool = _component("Tool", "lookup") + command = _component("Command", "admin") + parser = _component("Parser", "pdf", {"supported_mime_types": ["application/pdf"]}) + manager.plugins = [ + _plugin(name="demo", components=[tool, command, parser]), + _plugin(name="other", components=[_component("Tool", "skip")]), + ] + + assert manager.find_plugin("tester", "demo") is manager.plugins[0] + assert manager.find_plugin("tester", "missing") is None + + tools = asyncio.run(manager.list_tools(include_plugins=["tester/demo"])) + commands = asyncio.run(manager.list_commands(include_plugins=["tester/demo"])) + parsers = asyncio.run(manager.list_parsers()) + + assert [tool.metadata.name for tool in tools] == ["lookup"] + assert [command.metadata.name for command in commands] == ["admin"] + assert parsers[0]["plugin_id"] == "tester/demo" + assert parsers[0]["supported_mime_types"] == ["application/pdf"] + + +@pytest.mark.asyncio +async def test_install_plugin_from_file_extracts_manifest_and_replaces_old_version( + tmp_path, + monkeypatch, +): + monkeypatch.chdir(tmp_path) + manager = _manager() + old_plugin = _plugin(name="demo") + old_plugin._runtime_plugin_handler = FakeHandler(old_plugin) + manager.plugins = [old_plugin] + old_path = tmp_path / "data/plugins/tester__demo" + old_path.mkdir(parents=True) + (old_path / "old.py").write_text("", encoding="utf-8") + + new_path, author, name, version = await manager.install_plugin_from_file( + _plugin_zip(version="2.0.0") + ) + + assert (tmp_path / "data/plugins/tester__demo/manifest.yaml").exists() + assert new_path == "data/plugins/tester__demo" + assert (author, name, version) == ("tester", "demo", "2.0.0") + assert manager.plugins == [] + + +@pytest.mark.asyncio +async def test_install_plugin_from_file_rejects_same_version_duplicate(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + manager = _manager() + manager.plugins = [_plugin(name="demo")] + + with pytest.raises(ValueError, match="already exists"): + await manager.install_plugin_from_file(_plugin_zip(version="1.0.0")) + + +@pytest.mark.asyncio +async def test_register_plugin_initializes_settings_and_refreshes_container(): + manager = _manager() + control_handler = FakeControlHandler() + manager.context = SimpleNamespace(control_handler=control_handler) + plugin = _plugin( + name="demo", + components=[_component("Tool", "lookup")], + status=RuntimeContainerStatus.MOUNTED, + ) + handler = FakeHandler(plugin) + + await manager.register_plugin(handler, plugin.model_dump()) + + registered = manager.plugins[0] + assert registered._runtime_plugin_handler is handler + assert registered.install_source == PluginInstallSource.MARKETPLACE.value + assert registered.install_info == {"plugin_version": "1.0.0"} + assert registered.status is RuntimeContainerStatus.INITIALIZED + assert [component.manifest.metadata.name for component in registered.components] == [ + "lookup" + ] + assert handler.initialized_with["plugin_config"] == {"api_key": "secret"} + + +@pytest.mark.asyncio +async def test_call_tool_and_execute_command_delegate_to_connected_plugin(): + manager = _manager() + plugin = _plugin( + components=[ + _component("Tool", "lookup"), + _component("Command", "admin"), + ] + ) + plugin._runtime_plugin_handler = FakeHandler(plugin) + manager.plugins = [plugin] + + tool_response = await manager.call_tool( + "lookup", + {"city": "Paris"}, + {"launcher_type": "person"}, + query_id=99, + ) + command_responses = [ + response + async for response in manager.execute_command( + SimpleNamespace( + command="admin", + model_dump=lambda mode="json": {"command": "admin"}, + ) + ) + ] + + assert tool_response == { + "tool_name": "lookup", + "params": {"city": "Paris"}, + "query_id": 99, + } + assert command_responses == [CommandReturn(text="admin")] + + +@pytest.mark.asyncio +async def test_shutdown_plugin_closes_connection_and_removes_container(): + manager = _manager() + plugin = _plugin() + handler = FakeHandler(plugin) + plugin._runtime_plugin_handler = handler + manager.plugins = [plugin] + manager.plugin_handlers = [handler] + + await manager.shutdown_plugin(plugin) + + assert manager.plugins == [] + assert manager.plugin_handlers == [] + assert handler.shutdown_calls == 1 + assert handler.conn.closed is True + + +@pytest.mark.asyncio +async def test_plugin_resource_and_page_methods_delegate_to_connected_handler(): + manager = _manager() + plugin = _plugin() + handler = FakeHandler(plugin) + plugin._runtime_plugin_handler = handler + manager.plugins = [plugin] + + assert await manager.get_plugin_icon("tester", "missing") == (b"", "") + assert await manager.get_plugin_icon("tester", "demo") == ( + b"", + "image/svg+xml", + ) + assert "icon-key" not in handler.files + assert await manager.get_plugin_readme("tester", "demo", language="zh") == b"# Demo" + assert "readme-key" not in handler.files + assert await manager.get_plugin_assets_file("tester", "demo", "missing") == ( + b"", + "", + ) + assert await manager.get_plugin_assets_file("tester", "demo", "asset.txt") == ( + b"asset-bytes", + "text/plain", + ) + assert "asset-key" not in handler.files + + page_response = await manager.handle_page_api( + "tester", + "demo", + page_id="settings", + endpoint="/save", + method="POST", + body={"enabled": True}, + ) + assert page_response["data"]["page_id"] == "settings" + assert page_response["data"]["body"] == {"enabled": True} + assert await manager.handle_page_api( + "tester", + "missing", + page_id="settings", + endpoint="/save", + method="POST", + ) == {"data": None, "error": "Plugin not found"} + + +@pytest.mark.asyncio +async def test_knowledge_engine_methods_validate_components_and_delegate_to_handler(): + manager = _manager() + rag = _component( + "KnowledgeEngine", + "kb", + { + "creation_schema": [{"name": "api_key"}], + "retrieval_schema": [{"name": "top_k"}], + }, + ) + plugin = _plugin(components=[rag]) + plugin._runtime_plugin_handler = FakeHandler(plugin) + manager.plugins = [plugin] + + engines = await manager.list_knowledge_engines() + assert engines[0]["plugin_id"] == "tester/demo" + assert engines[0]["capabilities"] == ["ingest", "retrieve"] + assert engines[0]["creation_schema"] == {"schema": [{"name": "api_key"}]} + assert await manager.get_rag_creation_schema("tester", "demo") == { + "schema": [{"name": "api_key"}] + } + assert await manager.get_rag_retrieval_schema("tester", "demo") == { + "schema": [{"name": "top_k"}] + } + assert await manager.rag_ingest_document( + "tester", + "demo", + {"document_id": "doc-1"}, + ) == {"ingested": "doc-1"} + assert await manager.rag_delete_document("tester", "demo", "kb-1", "doc-1") == { + "deleted": ["kb-1", "doc-1"] + } + assert await manager.rag_on_kb_create( + "tester", + "demo", + "kb-1", + {"api_key": "secret"}, + ) == {"created": "kb-1", "config": {"api_key": "secret"}} + assert await manager.rag_on_kb_delete("tester", "demo", "kb-1") == { + "deleted_kb": "kb-1" + } + + with pytest.raises(ValueError, match="has no KnowledgeEngine component"): + manager.plugins = [_plugin(components=[])] + await manager.rag_ingest_document("tester", "demo", {}) + + +@pytest.mark.asyncio +async def test_parser_methods_validate_components_and_delegate_to_handler(): + manager = _manager() + parser = _component( + "Parser", + "pdf", + {"supported_mime_types": ["application/pdf"]}, + ) + plugin = _plugin(components=[parser]) + plugin._runtime_plugin_handler = FakeHandler(plugin) + manager.plugins = [plugin] + + parsers = await manager.list_parsers() + assert parsers[0]["plugin_id"] == "tester/demo" + assert parsers[0]["supported_mime_types"] == ["application/pdf"] + assert await manager.parse_document( + "tester", + "demo", + {"filename": "a.txt"}, + b"hello", + ) == {"context": {"filename": "a.txt"}, "text": "hello"} + + plugin._runtime_plugin_handler = None + with pytest.raises(ValueError, match="is not connected"): + await manager.parse_document("tester", "demo", {}, b"") + + +@pytest.mark.xfail( + strict=True, + reason="#64 emit_event appends emitted plugins twice when handler emits", +) +@pytest.mark.asyncio +async def test_emit_event_should_report_each_emitting_plugin_once(): + manager = _manager() + plugin = _plugin() + plugin._runtime_plugin_handler = FakeHandler(plugin) + manager.plugins = [plugin] + event_context = EventContext( + query_id=1, + event_name="PersonCommandSent", + event=PersonCommandSent( + launcher_type="person", + launcher_id="launcher", + sender_id="sender", + command="demo", + params=[], + text_message="/demo", + is_admin=False, + ), + ) + + emitted_plugins, _ = await manager.emit_event(event_context) + + assert emitted_plugins == [plugin] diff --git a/tests/runtime/test_app.py b/tests/runtime/test_app.py new file mode 100644 index 00000000..9d16f9fd --- /dev/null +++ b/tests/runtime/test_app.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import argparse +import asyncio + +from langbot_plugin.runtime import app as runtime_app + + +class FakePluginManager: + instances = [] + + def __init__(self, context): + self.context = context + self.wait_for_control_connection = None + self.calls = [] + self.handlers = [] + self.instances.append(self) + + async def ensure_all_plugins_dependencies_installed(self): + self.calls.append("ensure_deps") + + async def launch_all_plugins(self): + self.calls.append("launch_all") + + async def add_plugin_handler(self, handler): + self.calls.append("add_plugin_handler") + self.handlers.append(handler) + + async def shutdown_all_plugins(self): + self.calls.append("shutdown_all") + + +class FakeServerController: + instances = [] + + def __init__(self, port=None): + self.port = port + self.callbacks = [] + self.instances.append(self) + + async def run(self, callback): + self.callbacks.append(callback) + await callback(object()) + + +class FakeControlHandler: + instances = [] + + def __init__(self, connection, context): + self.connection = connection + self.context = context + self.calls = [] + self.instances.append(self) + + async def run(self): + self.calls.append("run") + + +class FakePluginHandler: + instances = [] + + def __init__(self, connection, context, debug_plugin=False): + self.connection = connection + self.context = context + self.debug_plugin = debug_plugin + self.instances.append(self) + + +def _args(**overrides): + defaults = { + "pypi_index_url": "", + "pypi_trusted_host": "", + "ws_debug_port": 5401, + "stdio_control": True, + "ws_control_port": 5400, + "skip_deps_check": False, + "debug_only": False, + } + defaults.update(overrides) + return argparse.Namespace(**defaults) + + +def test_runtime_application_initializes_stdio_control_mode(monkeypatch): + monkeypatch.setattr( + runtime_app.plugin_mgr_cls, + "PluginManager", + FakePluginManager, + ) + monkeypatch.setattr( + runtime_app.stdio_controller_server, + "StdioServerController", + FakeServerController, + ) + monkeypatch.setattr( + runtime_app.ws_controller_server, + "WebSocketServerController", + FakeServerController, + ) + + app = runtime_app.RuntimeApplication( + _args( + stdio_control=True, + pypi_index_url="https://mirror", + pypi_trusted_host="mirror", + ) + ) + + assert app._control_connection_mode is runtime_app.ControlConnectionMode.STDIO + assert isinstance(app.context.stdio_server, FakeServerController) + assert app.context.ws_control_server is None + assert app.context.ws_debug_server.port == 5401 + assert app.context.ws_debug_port == 5401 + assert runtime_app.os.environ["LANGBOT_PLUGIN_PYPI_INDEX_URL"] == "https://mirror" + assert runtime_app.os.environ["LANGBOT_PLUGIN_PYPI_TRUSTED_HOST"] == "mirror" + + +def test_runtime_application_initializes_websocket_control_mode(monkeypatch): + monkeypatch.setattr( + runtime_app.plugin_mgr_cls, + "PluginManager", + FakePluginManager, + ) + monkeypatch.setattr( + runtime_app.stdio_controller_server, + "StdioServerController", + FakeServerController, + ) + monkeypatch.setattr( + runtime_app.ws_controller_server, + "WebSocketServerController", + FakeServerController, + ) + + app = runtime_app.RuntimeApplication( + _args(stdio_control=False, ws_control_port=5500, ws_debug_port=5501) + ) + + assert app._control_connection_mode is runtime_app.ControlConnectionMode.WS + assert app.context.stdio_server is None + assert app.context.ws_control_server.port == 5500 + assert app.context.ws_debug_server.port == 5501 + + +async def test_set_control_handler_runs_handler_and_resolves_waiter(monkeypatch): + monkeypatch.setattr( + runtime_app.plugin_mgr_cls, + "PluginManager", + FakePluginManager, + ) + monkeypatch.setattr( + runtime_app.stdio_controller_server, + "StdioServerController", + FakeServerController, + ) + monkeypatch.setattr( + runtime_app.ws_controller_server, + "WebSocketServerController", + FakeServerController, + ) + app = runtime_app.RuntimeApplication(_args()) + app.context.plugin_mgr.wait_for_control_connection = asyncio.Future() + handler = FakeControlHandler(object(), app.context) + + task = app.set_control_handler(handler) + await task + + assert app.context.control_handler is handler + assert handler.calls == ["run"] + assert app.context.plugin_mgr.wait_for_control_connection is None + + +async def test_runtime_application_run_coordinates_servers_and_plugin_manager( + monkeypatch, +): + FakePluginManager.instances = [] + FakeControlHandler.instances = [] + FakePluginHandler.instances = [] + monkeypatch.setattr( + runtime_app.plugin_mgr_cls, + "PluginManager", + FakePluginManager, + ) + monkeypatch.setattr( + runtime_app.stdio_controller_server, + "StdioServerController", + FakeServerController, + ) + monkeypatch.setattr( + runtime_app.ws_controller_server, + "WebSocketServerController", + FakeServerController, + ) + monkeypatch.setattr( + runtime_app.control_handler_cls, + "ControlConnectionHandler", + FakeControlHandler, + ) + monkeypatch.setattr( + runtime_app.plugin_handler_cls, + "PluginConnectionHandler", + FakePluginHandler, + ) + app = runtime_app.RuntimeApplication(_args(stdio_control=True)) + + await app.run() + + manager = FakePluginManager.instances[-1] + assert manager.calls == [ + "ensure_deps", + "add_plugin_handler", + "launch_all", + ] + assert FakeControlHandler.instances[-1].calls == ["run"] + assert FakePluginHandler.instances[-1].debug_plugin is True + + +async def test_runtime_application_run_can_skip_deps_and_plugin_launch(monkeypatch): + FakePluginManager.instances = [] + monkeypatch.setattr( + runtime_app.plugin_mgr_cls, + "PluginManager", + FakePluginManager, + ) + monkeypatch.setattr( + runtime_app.stdio_controller_server, + "StdioServerController", + FakeServerController, + ) + monkeypatch.setattr( + runtime_app.ws_controller_server, + "WebSocketServerController", + FakeServerController, + ) + monkeypatch.setattr( + runtime_app.control_handler_cls, + "ControlConnectionHandler", + FakeControlHandler, + ) + monkeypatch.setattr( + runtime_app.plugin_handler_cls, + "PluginConnectionHandler", + FakePluginHandler, + ) + app = runtime_app.RuntimeApplication( + _args(skip_deps_check=True, debug_only=True) + ) + + await app.run() + + assert FakePluginManager.instances[-1].calls == ["add_plugin_handler"] + + +async def test_runtime_application_run_uses_websocket_control_server(monkeypatch): + FakePluginManager.instances = [] + FakeControlHandler.instances = [] + monkeypatch.setattr( + runtime_app.plugin_mgr_cls, + "PluginManager", + FakePluginManager, + ) + monkeypatch.setattr( + runtime_app.stdio_controller_server, + "StdioServerController", + FakeServerController, + ) + monkeypatch.setattr( + runtime_app.ws_controller_server, + "WebSocketServerController", + FakeServerController, + ) + monkeypatch.setattr( + runtime_app.control_handler_cls, + "ControlConnectionHandler", + FakeControlHandler, + ) + monkeypatch.setattr( + runtime_app.plugin_handler_cls, + "PluginConnectionHandler", + FakePluginHandler, + ) + app = runtime_app.RuntimeApplication( + _args(stdio_control=False, skip_deps_check=True, debug_only=True) + ) + + await app.run() + + assert app.context.ws_control_server.callbacks + assert FakeControlHandler.instances[-1].calls == ["run"] + assert FakePluginManager.instances[-1].calls == ["add_plugin_handler"] + + +async def test_runtime_application_shutdown_delegates_to_plugin_manager(monkeypatch): + FakePluginManager.instances = [] + monkeypatch.setattr( + runtime_app.plugin_mgr_cls, + "PluginManager", + FakePluginManager, + ) + monkeypatch.setattr( + runtime_app.stdio_controller_server, + "StdioServerController", + FakeServerController, + ) + monkeypatch.setattr( + runtime_app.ws_controller_server, + "WebSocketServerController", + FakeServerController, + ) + app = runtime_app.RuntimeApplication(_args()) + + await app.shutdown() + + assert FakePluginManager.instances[-1].calls == ["shutdown_all"] + + +def test_runtime_main_configures_logging_and_runs_application(monkeypatch): + calls = [] + + class FakeApplication: + def __init__(self, args): + calls.append(("init", args)) + + async def run(self): + calls.append(("run",)) + + monkeypatch.setattr( + runtime_app, + "configure_process_logging", + lambda: calls.append(("configure_logging",)), + ) + monkeypatch.setattr(runtime_app, "RuntimeApplication", FakeApplication) + + runtime_app.main(_args()) + + assert calls == [("configure_logging",), ("init", _args()), ("run",)] + + +def test_runtime_main_handles_cancelled_error(monkeypatch): + calls = [] + + class FakeApplication: + def __init__(self, args): + calls.append(("init", args)) + + def run(self): + return "coroutine" + + monkeypatch.setattr( + runtime_app, + "configure_process_logging", + lambda: calls.append(("configure_logging",)), + ) + monkeypatch.setattr(runtime_app, "RuntimeApplication", FakeApplication) + monkeypatch.setattr( + runtime_app.asyncio, + "run", + lambda coroutine: (_ for _ in ()).throw(asyncio.CancelledError()), + ) + + runtime_app.main(_args()) + + assert calls == [("configure_logging",), ("init", _args())] + + +def test_runtime_main_handles_keyboard_interrupt(monkeypatch): + calls = [] + + class FakeApplication: + def __init__(self, args): + calls.append(("init", args)) + + def run(self): + return "coroutine" + + monkeypatch.setattr( + runtime_app, + "configure_process_logging", + lambda: calls.append(("configure_logging",)), + ) + monkeypatch.setattr(runtime_app, "RuntimeApplication", FakeApplication) + monkeypatch.setattr( + runtime_app.asyncio, + "run", + lambda coroutine: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + runtime_app.main(_args()) + + assert calls == [("configure_logging",), ("init", _args())] diff --git a/tests/utils/test_discovery.py b/tests/utils/test_discovery.py new file mode 100644 index 00000000..9b771d32 --- /dev/null +++ b/tests/utils/test_discovery.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import yaml +import pytest + +from langbot_plugin.utils.discover.engine import ComponentDiscoveryEngine + + +def _write_manifest(path, *, kind="Tool", name="demo") -> None: + path.write_text( + yaml.safe_dump( + { + "apiVersion": "v1", + "kind": kind, + "metadata": { + "name": name, + "label": {"en_US": name.title()}, + }, + "spec": {}, + } + ), + encoding="utf-8", + ) + + +def test_load_component_manifest_returns_none_for_non_component_yaml(tmp_path): + path = tmp_path / "plain.yaml" + path.write_text("name: plain\n", encoding="utf-8") + + engine = ComponentDiscoveryEngine() + + assert engine.load_component_manifest(str(path)) is None + + +def test_load_component_manifest_can_skip_registry_save(tmp_path): + path = tmp_path / "tool.yaml" + _write_manifest(path) + engine = ComponentDiscoveryEngine() + + component = engine.load_component_manifest(str(path), owner="plugin", no_save=True) + + assert component is not None + assert component.owner == "plugin" + assert component.metadata.name == "demo" + assert engine.get_components_by_kind("Tool") == [] + + +def test_load_component_manifests_in_dir_respects_depth_and_file_extension(tmp_path): + _write_manifest(tmp_path / "root.yaml", kind="Command", name="root") + nested = tmp_path / "nested" + nested.mkdir() + _write_manifest(nested / "nested.yml", kind="Tool", name="nested") + too_deep = nested / "too_deep" + too_deep.mkdir() + _write_manifest(too_deep / "ignored.yaml", kind="Page", name="ignored") + (tmp_path / "README.md").write_text("ignore me", encoding="utf-8") + + engine = ComponentDiscoveryEngine() + components = engine.load_component_manifests_in_dir(str(tmp_path), max_depth=2) + + assert {component.metadata.name for component in components} == {"root", "nested"} + assert [component.metadata.name for component in engine.get_components_by_kind("Command")] == [ + "root" + ] + assert [component.metadata.name for component in engine.get_components_by_kind("Tool")] == [ + "nested" + ] + + +def test_discover_blueprint_loads_templates_before_other_component_groups(tmp_path): + template = tmp_path / "template.yaml" + tool = tmp_path / "tool.yaml" + blueprint = tmp_path / "blueprint.yaml" + _write_manifest(template, kind="ComponentTemplate", name="base") + _write_manifest(tool, kind="Tool", name="weather") + blueprint.write_text( + yaml.safe_dump( + { + "apiVersion": "v1", + "kind": "Blueprint", + "metadata": {"name": "bp", "label": {"en_US": "Blueprint"}}, + "spec": { + "components": { + "ComponentTemplate": {"fromFiles": [str(template)]}, + "Tool": {"fromFiles": [str(tool)]}, + } + }, + } + ), + encoding="utf-8", + ) + + engine = ComponentDiscoveryEngine() + blueprint_manifest, components = engine.discover_blueprint(str(blueprint)) + + assert blueprint_manifest.kind == "Blueprint" + assert list(components) == ["ComponentTemplate", "Tool"] + assert components["ComponentTemplate"][0].metadata.name == "base" + assert components["Tool"][0].metadata.name == "weather" + + +def test_find_components_filters_supplied_component_list(tmp_path): + tool = tmp_path / "tool.yaml" + command = tmp_path / "command.yaml" + _write_manifest(tool, kind="Tool", name="weather") + _write_manifest(command, kind="Command", name="weather_cmd") + engine = ComponentDiscoveryEngine() + components = engine.load_component_manifests_in_dir(str(tmp_path)) + + assert [c.kind for c in engine.find_components("Tool", components)] == ["Tool"] + + +@pytest.mark.xfail( + strict=True, + reason="#56 ComponentDiscoveryEngine.components is shared by instances", +) +def test_component_registry_should_be_isolated_per_engine_instance(tmp_path): + path = tmp_path / "tool.yaml" + _write_manifest(path) + + first = ComponentDiscoveryEngine() + second = ComponentDiscoveryEngine() + first.load_component_manifest(str(path), no_save=False) + + assert second.get_components_by_kind("Tool") == [] diff --git a/tests/utils/test_importutil.py b/tests/utils/test_importutil.py new file mode 100644 index 00000000..29dd13af --- /dev/null +++ b/tests/utils/test_importutil.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import importlib +import sys +import textwrap + +import pytest + +from langbot_plugin.utils import importutil + + +@pytest.mark.xfail( + strict=True, + reason="#57 import_dot_style_dir/import_dir rewrite non-SDK paths incorrectly", +) +def test_import_dot_style_dir_imports_python_modules_from_package(tmp_path, monkeypatch): + package = tmp_path / "samplepkg" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "alpha.py").write_text("VALUE = 42\n", encoding="utf-8") + (package / "notes.txt").write_text("ignored", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + + importutil.import_dot_style_dir("samplepkg") + + assert importlib.import_module("samplepkg.alpha").VALUE == 42 + + +@pytest.mark.xfail( + strict=True, + reason="#57 import_modules_in_pkg fails outside langbot_plugin source root", +) +def test_import_modules_in_pkg_uses_package_file_location(tmp_path, monkeypatch): + package = tmp_path / "anotherpkg" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "beta.py").write_text("FLAG = 'loaded'\n", encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + pkg = importlib.import_module("anotherpkg") + + importutil.import_modules_in_pkg(pkg) + + assert sys.modules["anotherpkg.beta"].FLAG == "loaded" + + +@pytest.mark.xfail( + strict=True, + reason="#57 import_dir rewrites arbitrary paths into invalid module names", +) +def test_import_dir_skips_init_files(tmp_path, monkeypatch): + package = tmp_path / "thirdpkg" + package.mkdir() + (package / "__init__.py").write_text("RAISED = False\n", encoding="utf-8") + (package / "gamma.py").write_text( + textwrap.dedent( + """ + RESULT = "ok" + """ + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + + importutil.import_dir(str(package)) + + assert importlib.import_module("thirdpkg.gamma").RESULT == "ok" diff --git a/tests/utils/test_platform.py b/tests/utils/test_platform.py new file mode 100644 index 00000000..97623adb --- /dev/null +++ b/tests/utils/test_platform.py @@ -0,0 +1,9 @@ +import sys + +from langbot_plugin.utils.platform import get_platform + + +def test_get_platform_returns_current_sys_platform(monkeypatch): + monkeypatch.setattr(sys, "platform", "test-platform") + + assert get_platform() == "test-platform" From 9723b59d8ac428cd6879fa0baec5b56becbbf461 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 16 May 2026 20:01:31 +0800 Subject: [PATCH 2/2] fix: resolve recorded SDK test-build issues --- .../api/entities/builtin/command/context.py | 5 +-- .../api/entities/builtin/command/errors.py | 8 ++-- .../api/entities/builtin/provider/message.py | 5 ++- src/langbot_plugin/runtime/io/handler.py | 6 ++- .../runtime/plugin/container.py | 7 ++-- src/langbot_plugin/runtime/plugin/mgr.py | 4 +- src/langbot_plugin/utils/discover/engine.py | 3 ++ src/langbot_plugin/utils/importutil.py | 36 +++++++++++----- .../entities/builtin/test_command_context.py | 8 ---- .../entities/builtin/test_provider_message.py | 4 -- .../io/handlers/test_import_contracts.py | 6 --- tests/runtime/io/test_handler.py | 4 -- tests/runtime/plugin/test_container.py | 6 --- tests/runtime/plugin/test_manager.py | 42 +++++++++++++++---- tests/utils/test_discovery.py | 5 --- tests/utils/test_importutil.py | 14 ------- 16 files changed, 81 insertions(+), 82 deletions(-) diff --git a/src/langbot_plugin/api/entities/builtin/command/context.py b/src/langbot_plugin/api/entities/builtin/command/context.py index 4d399ea1..7d8d9a60 100644 --- a/src/langbot_plugin/api/entities/builtin/command/context.py +++ b/src/langbot_plugin/api/entities/builtin/command/context.py @@ -37,8 +37,8 @@ class CommandReturn(pydantic.BaseModel): """错误,保留供系统使用,插件逻辑报错请自行使用 text 传递 """ - @classmethod @pydantic.field_validator("error", mode="before") + @classmethod def _validate_error( cls, v: Optional[errors.CommandError] ) -> Optional[errors.CommandError]: @@ -46,9 +46,8 @@ def _validate_error( return errors.CommandError(message=v.message) return v - @classmethod @pydantic.field_serializer("error") - def _serialize_error(cls, v: Optional[errors.CommandError]) -> Optional[str]: + def _serialize_error(self, v: Optional[errors.CommandError]) -> Optional[str]: if v is not None: return v.message return v diff --git a/src/langbot_plugin/api/entities/builtin/command/errors.py b/src/langbot_plugin/api/entities/builtin/command/errors.py index 2d58650e..d1456bb7 100644 --- a/src/langbot_plugin/api/entities/builtin/command/errors.py +++ b/src/langbot_plugin/api/entities/builtin/command/errors.py @@ -13,20 +13,20 @@ def __str__(self): class CommandNotFoundError(CommandError): - def __init__(self, message: str = None): + def __init__(self, message: str = ""): super().__init__("未知命令: " + message) class CommandPrivilegeError(CommandError): - def __init__(self, message: str = None): + def __init__(self, message: str = ""): super().__init__("权限不足: " + message) class ParamNotEnoughError(CommandError): - def __init__(self, message: str = None): + def __init__(self, message: str = ""): super().__init__("参数不足: " + message) class CommandOperationError(CommandError): - def __init__(self, message: str = None): + def __init__(self, message: str = ""): super().__init__("操作失败: " + message) diff --git a/src/langbot_plugin/api/entities/builtin/provider/message.py b/src/langbot_plugin/api/entities/builtin/provider/message.py index 69d1825c..985433cd 100644 --- a/src/langbot_plugin/api/entities/builtin/provider/message.py +++ b/src/langbot_plugin/api/entities/builtin/provider/message.py @@ -132,7 +132,8 @@ def get_content_platform_message_chain( platform_message.File(url=ce.file_url, name=ce.file_name) ) elif ce.type == "image_url": - assert ce.image_url is not None + if ce.image_url is None: + raise ValueError("image_url content requires image_url payload") if ce.image_url.url.startswith("http"): mc.append(platform_message.Image(url=ce.image_url.url)) # else: # base64, for backward compatibility @@ -223,6 +224,8 @@ def get_content_platform_message_chain( platform_message.File(url=ce.file_url, name=ce.file_name) ) elif ce.type == "image_url": + if ce.image_url is None: + raise ValueError("image_url content requires image_url payload") if ce.image_url.url.startswith("http"): mc.append(platform_message.Image(url=ce.image_url.url)) # else: # base64 diff --git a/src/langbot_plugin/runtime/io/handler.py b/src/langbot_plugin/runtime/io/handler.py index 1436c943..0474044f 100644 --- a/src/langbot_plugin/runtime/io/handler.py +++ b/src/langbot_plugin/runtime/io/handler.py @@ -70,11 +70,9 @@ def __init__( @self.action(CommonAction.FILE_CHUNK) async def file_chunk(data: dict[str, Any]) -> ActionResponse: file_key = data["file_key"] - file_length = data["file_length"] chunk_base64 = data["chunk_base64"] chunk_index = data["chunk_index"] chunk_amount = data["chunk_amount"] - chunk_size = data["chunk_size"] # append the chunk to the file async with aiofiles.open( os.path.join(FILE_STORAGE_DIR, file_key), "ab" @@ -180,6 +178,8 @@ async def call_action( return response.data except asyncio.TimeoutError: raise ActionCallTimeoutError(f"Action {action.value} call timed out") + except ActionCallError: + raise except Exception as e: raise ActionCallError(f"{e.__class__.__name__}: {str(e)}") finally: @@ -218,6 +218,8 @@ async def call_action_generator( raise ActionCallTimeoutError( f"Action {action.value} call timed out" ) + except ActionCallError: + raise except Exception as e: raise ActionCallError(f"{e.__class__.__name__}: {str(e)}") finally: diff --git a/src/langbot_plugin/runtime/plugin/container.py b/src/langbot_plugin/runtime/plugin/container.py index 57bb927d..642f495b 100644 --- a/src/langbot_plugin/runtime/plugin/container.py +++ b/src/langbot_plugin/runtime/plugin/container.py @@ -10,7 +10,6 @@ from langbot_plugin.api.definition.plugin import BasePlugin from langbot_plugin.api.definition.components.base import BaseComponent, NoneComponent from langbot_plugin.api.definition.components.manifest import ComponentManifest -from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler class RuntimeContainerStatus(enum.Enum): @@ -59,9 +58,7 @@ class PluginContainer(pydantic.BaseModel): components: list[ComponentContainer] """组件容器列表""" - _runtime_plugin_handler: PluginConnectionHandler | None = pydantic.PrivateAttr( - default=None - ) + _runtime_plugin_handler: typing.Any = pydantic.PrivateAttr(default=None) class Config: arbitrary_types_allowed = True @@ -84,6 +81,8 @@ def model_dump(self): def from_dict(cls, data: dict[str, typing.Any]) -> PluginContainer: return cls( debug=data["debug"], + install_source=data.get("install_source", ""), + install_info=data.get("install_info", {}), manifest=ComponentManifest.model_validate(data["manifest"]), plugin_instance=NonePlugin(), enabled=data["enabled"], diff --git a/src/langbot_plugin/runtime/plugin/mgr.py b/src/langbot_plugin/runtime/plugin/mgr.py index ed874847..f17ea992 100644 --- a/src/langbot_plugin/runtime/plugin/mgr.py +++ b/src/langbot_plugin/runtime/plugin/mgr.py @@ -67,6 +67,8 @@ class PluginManager: def __init__(self, context: context_module.RuntimeContext): self.context = context + self.plugin_handlers = [] + self.plugins = [] self.plugin_run_tasks = [] self.wait_for_control_connection = None @@ -621,8 +623,6 @@ async def emit_event( if resp["emitted"]: emitted_plugins.append(plugin) - emitted_plugins.append(plugin) - event_context = EventContext.model_validate(resp["event_context"]) if event_context.is_prevented_postorder(): diff --git a/src/langbot_plugin/utils/discover/engine.py b/src/langbot_plugin/utils/discover/engine.py index 78093566..d4cddbb5 100644 --- a/src/langbot_plugin/utils/discover/engine.py +++ b/src/langbot_plugin/utils/discover/engine.py @@ -14,6 +14,9 @@ class ComponentDiscoveryEngine: components: typing.Dict[str, typing.List[ComponentManifest]] = {} """组件列表""" + def __init__(self): + self.components = {} + def load_component_manifest( self, path: str, owner: str = "builtin", no_save: bool = False ) -> ComponentManifest | None: diff --git a/src/langbot_plugin/utils/importutil.py b/src/langbot_plugin/utils/importutil.py index a070c094..4b26ae3e 100644 --- a/src/langbot_plugin/utils/importutil.py +++ b/src/langbot_plugin/utils/importutil.py @@ -1,6 +1,8 @@ import importlib import importlib.util import os +import pkgutil +import sys import typing @@ -10,6 +12,12 @@ def import_modules_in_pkg(pkg: typing.Any) -> None: Args: pkg: 要导入的包对象 """ + if hasattr(pkg, "__path__"): + for module_info in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."): + if not module_info.ispkg: + importlib.import_module(module_info.name) + return + pkg_path = os.path.dirname(pkg.__file__) import_dir(pkg_path) @@ -20,19 +28,25 @@ def import_modules_in_pkgs(pkgs: typing.List) -> None: def import_dot_style_dir(dot_sep_path: str): - sec = dot_sep_path.split(".") - - return import_dir(os.path.join(*sec)) + pkg = importlib.import_module(dot_sep_path) + return import_modules_in_pkg(pkg) def import_dir(path: str): + abs_path = os.path.abspath(path) for file in os.listdir(path): if file.endswith(".py") and file != "__init__.py": - full_path = os.path.join(path, file) - rel_path = full_path.replace( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "" - ) - rel_path = rel_path[1:] - rel_path = rel_path.replace("/", ".")[:-3] - rel_path = rel_path.replace("\\", ".") - importlib.import_module(rel_path) + full_path = os.path.abspath(os.path.join(abs_path, file)) + module_path = full_path[:-3] + for root in sorted(sys.path, key=len, reverse=True): + if not root: + root = os.getcwd() + abs_root = os.path.abspath(root) + try: + rel_path = os.path.relpath(module_path, abs_root) + except ValueError: + continue + if rel_path.startswith(".."): + continue + importlib.import_module(rel_path.replace(os.sep, ".")) + break diff --git a/tests/api/entities/builtin/test_command_context.py b/tests/api/entities/builtin/test_command_context.py index 13c86e2b..f8571913 100644 --- a/tests/api/entities/builtin/test_command_context.py +++ b/tests/api/entities/builtin/test_command_context.py @@ -27,10 +27,6 @@ def _session() -> Session: ) -@pytest.mark.xfail( - strict=True, - reason="#59 CommandReturn error serializer is not applied by dumps", -) def test_command_return_serializes_command_error_to_message(): ret = CommandReturn(error=CommandError(message="failed")) @@ -64,10 +60,6 @@ def test_execute_context_shift_advances_current_command_and_params(): assert context.crt_params == [] -@pytest.mark.xfail( - strict=True, - reason="#59 CommandNotFoundError defaults message to None but concatenates it", -) def test_command_not_found_error_default_message_should_be_constructible(): assert str(CommandNotFoundError()) == "未知命令: " diff --git a/tests/api/entities/builtin/test_provider_message.py b/tests/api/entities/builtin/test_provider_message.py index 1220ea76..3f4a6e41 100644 --- a/tests/api/entities/builtin/test_provider_message.py +++ b/tests/api/entities/builtin/test_provider_message.py @@ -100,10 +100,6 @@ def test_message_chunk_matches_message_content_conversion(): assert chunk.readable_str() == "assistant: partial" -@pytest.mark.xfail( - strict=True, - reason="#60 ContentElement allows type='image_url' without image_url payload", -) def test_message_image_url_content_should_validate_required_payload(): message = Message(role="user", content=[ContentElement(type="image_url")]) diff --git a/tests/runtime/io/handlers/test_import_contracts.py b/tests/runtime/io/handlers/test_import_contracts.py index 7166cd43..15a0a400 100644 --- a/tests/runtime/io/handlers/test_import_contracts.py +++ b/tests/runtime/io/handlers/test_import_contracts.py @@ -3,13 +3,7 @@ import subprocess import sys -import pytest - -@pytest.mark.xfail( - strict=True, - reason="#62 PluginConnectionHandler direct import fails due to circular import", -) def test_plugin_connection_handler_should_be_directly_importable(): result = subprocess.run( [ diff --git a/tests/runtime/io/test_handler.py b/tests/runtime/io/test_handler.py index c9c865d8..61ac55af 100644 --- a/tests/runtime/io/test_handler.py +++ b/tests/runtime/io/test_handler.py @@ -81,10 +81,6 @@ async def test_call_action_timeout_cleans_waiter(): assert handler.resp_waiters == {} -@pytest.mark.xfail( - strict=True, - reason="#58 call_action wraps ActionCallError in a second ActionCallError", -) @pytest.mark.asyncio async def test_call_action_error_response_should_preserve_peer_message(): conn = QueueConnection() diff --git a/tests/runtime/plugin/test_container.py b/tests/runtime/plugin/test_container.py index a5e97d89..f0edf1e8 100644 --- a/tests/runtime/plugin/test_container.py +++ b/tests/runtime/plugin/test_container.py @@ -1,7 +1,5 @@ from __future__ import annotations -import pytest - from langbot_plugin.api.definition.components.base import NoneComponent from langbot_plugin.api.definition.components.manifest import ComponentManifest from langbot_plugin.api.definition.plugin import NonePlugin @@ -106,10 +104,6 @@ def test_plugin_container_roundtrip_uses_none_plugin_placeholder(): assert restored.components == [] -@pytest.mark.xfail( - strict=True, - reason="#61 PluginContainer.from_dict drops install_source/install_info fields", -) def test_plugin_container_roundtrip_should_preserve_install_metadata(): container = PluginContainer( debug=False, diff --git a/tests/runtime/plugin/test_manager.py b/tests/runtime/plugin/test_manager.py index 4dfec4da..3349ccb8 100644 --- a/tests/runtime/plugin/test_manager.py +++ b/tests/runtime/plugin/test_manager.py @@ -212,10 +212,6 @@ async def parse_document(self, context_data, file_bytes): return {"context": context_data, "text": file_bytes.decode()} -@pytest.mark.xfail( - strict=True, - reason="#63 PluginManager instances share plugins/plugin_handlers class lists", -) def test_plugin_manager_instances_should_not_share_plugin_state(): PluginManager.plugins = [] first = PluginManager(SimpleNamespace()) @@ -283,6 +279,40 @@ async def test_install_plugin_from_file_rejects_same_version_duplicate(tmp_path, await manager.install_plugin_from_file(_plugin_zip(version="1.0.0")) +@pytest.mark.asyncio +async def test_install_plugin_raises_when_dependency_install_fails(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + plugin_path = tmp_path / "data/plugins/tester__demo" + plugin_path.mkdir(parents=True) + (plugin_path / "requirements.txt").write_text("missing-package\n", encoding="utf-8") + manager = _manager() + + async def fake_install_from_file(plugin_file): + return "data/plugins/tester__demo", "tester", "demo", "1.0.0" + + async def fake_install_single_async(dep): + return 1, 0, "pip could not find package" + + monkeypatch.setattr(manager, "install_plugin_from_file", fake_install_from_file) + monkeypatch.setattr( + "langbot_plugin.runtime.plugin.mgr.pkgmgr_helper.install_single_async", + fake_install_single_async, + ) + + progress = [] + with pytest.raises(RuntimeError, match="pip could not find package"): + async for item in manager.install_plugin( + PluginInstallSource.LOCAL, + {"plugin_file": b"zip"}, + ): + progress.append(item["current_action"]) + + assert progress[0] == "downloading plugin package" + assert "installing dependencies" in progress + assert "initializing plugin settings" not in progress + assert "launching plugin" not in progress + + @pytest.mark.asyncio async def test_register_plugin_initializes_settings_and_refreshes_container(): manager = _manager() @@ -481,10 +511,6 @@ async def test_parser_methods_validate_components_and_delegate_to_handler(): await manager.parse_document("tester", "demo", {}, b"") -@pytest.mark.xfail( - strict=True, - reason="#64 emit_event appends emitted plugins twice when handler emits", -) @pytest.mark.asyncio async def test_emit_event_should_report_each_emitting_plugin_once(): manager = _manager() diff --git a/tests/utils/test_discovery.py b/tests/utils/test_discovery.py index 9b771d32..eb12e9a3 100644 --- a/tests/utils/test_discovery.py +++ b/tests/utils/test_discovery.py @@ -1,7 +1,6 @@ from __future__ import annotations import yaml -import pytest from langbot_plugin.utils.discover.engine import ComponentDiscoveryEngine @@ -110,10 +109,6 @@ def test_find_components_filters_supplied_component_list(tmp_path): assert [c.kind for c in engine.find_components("Tool", components)] == ["Tool"] -@pytest.mark.xfail( - strict=True, - reason="#56 ComponentDiscoveryEngine.components is shared by instances", -) def test_component_registry_should_be_isolated_per_engine_instance(tmp_path): path = tmp_path / "tool.yaml" _write_manifest(path) diff --git a/tests/utils/test_importutil.py b/tests/utils/test_importutil.py index 29dd13af..fecbde2d 100644 --- a/tests/utils/test_importutil.py +++ b/tests/utils/test_importutil.py @@ -4,15 +4,9 @@ import sys import textwrap -import pytest - from langbot_plugin.utils import importutil -@pytest.mark.xfail( - strict=True, - reason="#57 import_dot_style_dir/import_dir rewrite non-SDK paths incorrectly", -) def test_import_dot_style_dir_imports_python_modules_from_package(tmp_path, monkeypatch): package = tmp_path / "samplepkg" package.mkdir() @@ -27,10 +21,6 @@ def test_import_dot_style_dir_imports_python_modules_from_package(tmp_path, monk assert importlib.import_module("samplepkg.alpha").VALUE == 42 -@pytest.mark.xfail( - strict=True, - reason="#57 import_modules_in_pkg fails outside langbot_plugin source root", -) def test_import_modules_in_pkg_uses_package_file_location(tmp_path, monkeypatch): package = tmp_path / "anotherpkg" package.mkdir() @@ -44,10 +34,6 @@ def test_import_modules_in_pkg_uses_package_file_location(tmp_path, monkeypatch) assert sys.modules["anotherpkg.beta"].FLAG == "loaded" -@pytest.mark.xfail( - strict=True, - reason="#57 import_dir rewrites arbitrary paths into invalid module names", -) def test_import_dir_skips_init_files(tmp_path, monkeypatch): package = tmp_path / "thirdpkg" package.mkdir()