diff --git a/superset-core/src/superset_core/extensions/types.py b/superset-core/src/superset_core/extensions/types.py index bb869944d1f..04ef037da50 100644 --- a/superset-core/src/superset_core/extensions/types.py +++ b/superset-core/src/superset_core/extensions/types.py @@ -15,49 +15,190 @@ # specific language governing permissions and limitations # under the License. -from typing import TypedDict +""" +Pydantic models for extension configuration and manifest validation. + +Two distinct schemas: +- ExtensionConfig: Source configuration (extension.json) authored by developers +- Manifest: Built output (manifest.json) generated by the build tool +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field # noqa: I001 + +# ============================================================================= +# Shared components +# ============================================================================= -class ModuleFederationConfig(TypedDict): - exposes: dict[str, str] - filename: str - shared: dict[str, str] - remotes: dict[str, str] +class ModuleFederationConfig(BaseModel): + """Configuration for Webpack Module Federation.""" + + exposes: list[str] = Field( + default_factory=list, + description="Modules exposed by this extension", + ) + filename: str = Field( + default="remoteEntry.js", + description="Remote entry filename", + ) + shared: dict[str, Any] = Field( + default_factory=dict, + description="Shared dependencies configuration", + ) + remotes: dict[str, str] = Field( + default_factory=dict, + description="Remote module references", + ) -class FrontendContributionConfig(TypedDict): - commands: dict[str, list[dict[str, str]]] - views: dict[str, list[dict[str, str]]] - menus: dict[str, list[dict[str, str]]] +class ContributionConfig(BaseModel): + """Configuration for frontend UI contributions.""" + + commands: list[dict[str, Any]] = Field( + default_factory=list, + description="Command contributions", + ) + views: dict[str, list[dict[str, Any]]] = Field( + default_factory=dict, + description="View contributions by location", + ) + menus: dict[str, Any] = Field( + default_factory=dict, + description="Menu contributions", + ) -class FrontendManifest(TypedDict): - contributions: FrontendContributionConfig - moduleFederation: ModuleFederationConfig - remoteEntry: str +class BaseExtension(BaseModel): + """Base fields shared by ExtensionConfig and Manifest.""" + + id: str = Field( + ..., + description="Unique extension identifier", + min_length=1, + ) + name: str = Field( + ..., + description="Human-readable extension name", + min_length=1, + ) + version: str = Field( + default="0.0.0", + description="Semantic version string", + pattern=r"^\d+\.\d+\.\d+$", + ) + license: str | None = Field( + default=None, + description="License identifier (e.g., 'Apache-2.0')", + ) + description: str | None = Field( + default=None, + description="Extension description", + ) + dependencies: list[str] = Field( + default_factory=list, + description="List of extension IDs this extension depends on", + ) + permissions: list[str] = Field( + default_factory=list, + description="Permissions required by this extension", + ) -class BackendManifest(TypedDict): - entryPoints: list[str] +# ============================================================================= +# ExtensionConfig (extension.json) +# ============================================================================= -class SharedBase(TypedDict, total=False): - id: str - name: str - dependencies: list[str] - description: str - version: str - frontend: FrontendManifest - permissions: list[str] +class ExtensionConfigFrontend(BaseModel): + """Frontend section in extension.json.""" + + contributions: ContributionConfig = Field( + default_factory=ContributionConfig, + description="UI contribution points", + ) + moduleFederation: ModuleFederationConfig = Field( # noqa: N815 + default_factory=ModuleFederationConfig, + description="Module Federation configuration", + ) -class Manifest(SharedBase, total=False): - backend: BackendManifest +class ExtensionConfigBackend(BaseModel): + """Backend section in extension.json.""" + + entryPoints: list[str] = Field( # noqa: N815 + default_factory=list, + description="Python module entry points to load", + ) + files: list[str] = Field( + default_factory=list, + description="Glob patterns for backend Python files", + ) -class BackendMetadata(BackendManifest): - files: list[str] +class ExtensionConfig(BaseExtension): + """ + Schema for extension.json (source configuration). + + This file is authored by developers to define extension metadata. + """ + + frontend: ExtensionConfigFrontend | None = Field( + default=None, + description="Frontend configuration", + ) + backend: ExtensionConfigBackend | None = Field( + default=None, + description="Backend configuration", + ) -class Metadata(SharedBase): - backend: BackendMetadata +# ============================================================================= +# Manifest (manifest.json) +# ============================================================================= + + +class ManifestFrontend(BaseModel): + """Frontend section in manifest.json.""" + + contributions: ContributionConfig = Field( + default_factory=ContributionConfig, + description="UI contribution points", + ) + moduleFederation: ModuleFederationConfig = Field( # noqa: N815 + default_factory=ModuleFederationConfig, + description="Module Federation configuration", + ) + remoteEntry: str = Field( # noqa: N815 + ..., + description="Path to the built remote entry file", + ) + + +class ManifestBackend(BaseModel): + """Backend section in manifest.json.""" + + entryPoints: list[str] = Field( # noqa: N815 + default_factory=list, + description="Python module entry points to load", + ) + + +class Manifest(BaseExtension): + """ + Schema for manifest.json (built output). + + This file is generated by the build tool from extension.json. + """ + + frontend: ManifestFrontend | None = Field( + default=None, + description="Frontend manifest", + ) + backend: ManifestBackend | None = Field( + default=None, + description="Backend manifest", + ) diff --git a/superset-extensions-cli/src/superset_extensions_cli/cli.py b/superset-extensions-cli/src/superset_extensions_cli/cli.py index 354260b931d..2eed72e1382 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/cli.py +++ b/superset-extensions-cli/src/superset_extensions_cli/cli.py @@ -23,12 +23,17 @@ import sys import time import zipfile from pathlib import Path -from typing import Any, Callable, cast +from typing import Any, Callable import click import semver from jinja2 import Environment, FileSystemLoader -from superset_core.extensions.types import Manifest, Metadata +from superset_core.extensions.types import ( + ExtensionConfig, + Manifest, + ManifestBackend, + ManifestFrontend, +) from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer @@ -125,40 +130,40 @@ def clean_dist_frontend(cwd: Path) -> None: def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest: - extension: Metadata = cast(Metadata, read_json(cwd / "extension.json")) - if not extension: + extension_data = read_json(cwd / "extension.json") + if not extension_data: click.secho("❌ extension.json not found.", err=True, fg="red") sys.exit(1) - manifest: Manifest = { - "id": extension["id"], - "name": extension["name"], - "version": extension["version"], - "permissions": extension["permissions"], - "dependencies": extension.get("dependencies", []), - } - if ( - (frontend := extension.get("frontend")) - and (contributions := frontend.get("contributions")) - and (module_federation := frontend.get("moduleFederation")) - and remote_entry - ): - manifest["frontend"] = { - "contributions": contributions, - "moduleFederation": module_federation, - "remoteEntry": remote_entry, - } + extension = ExtensionConfig.model_validate(extension_data) - if entry_points := extension.get("backend", {}).get("entryPoints"): - manifest["backend"] = {"entryPoints": entry_points} + frontend: ManifestFrontend | None = None + if extension.frontend and remote_entry: + frontend = ManifestFrontend( + contributions=extension.frontend.contributions, + moduleFederation=extension.frontend.moduleFederation, + remoteEntry=remote_entry, + ) - return manifest + backend: ManifestBackend | None = None + if extension.backend and extension.backend.entryPoints: + backend = ManifestBackend(entryPoints=extension.backend.entryPoints) + + return Manifest( + id=extension.id, + name=extension.name, + version=extension.version, + permissions=extension.permissions, + dependencies=extension.dependencies, + frontend=frontend, + backend=backend, + ) def write_manifest(cwd: Path, manifest: Manifest) -> None: dist_dir = cwd / "dist" (dist_dir / "manifest.json").write_text( - json.dumps(manifest, indent=2, sort_keys=True) + manifest.model_dump_json(indent=2, exclude_none=True, by_alias=True) ) click.secho("✅ Manifest updated", fg="green") diff --git a/superset-extensions-cli/tests/test_cli_build.py b/superset-extensions-cli/tests/test_cli_build.py index 9f4e70f8b11..7704a2dccd3 100644 --- a/superset-extensions-cli/tests/test_cli_build.py +++ b/superset-extensions-cli/tests/test_cli_build.py @@ -236,7 +236,7 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem): "permissions": ["read_data"], "dependencies": ["some_dep"], "frontend": { - "contributions": {"commands": ["test_command"]}, + "contributions": {"commands": [{"id": "test_command", "title": "Test"}]}, "moduleFederation": {"exposes": ["./index"]}, }, "backend": {"entryPoints": ["test_extension.entrypoint"]}, @@ -247,23 +247,23 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem): manifest = build_manifest(isolated_filesystem, "remoteEntry.abc123.js") # Verify manifest structure - manifest_dict = dict(manifest) - assert manifest_dict["id"] == "test_extension" - assert manifest_dict["name"] == "Test Extension" - assert manifest_dict["version"] == "1.0.0" - assert manifest_dict["permissions"] == ["read_data"] - assert manifest_dict["dependencies"] == ["some_dep"] + assert manifest.id == "test_extension" + assert manifest.name == "Test Extension" + assert manifest.version == "1.0.0" + assert manifest.permissions == ["read_data"] + assert manifest.dependencies == ["some_dep"] # Verify frontend section - assert "frontend" in manifest - frontend = manifest["frontend"] - assert frontend["contributions"] == {"commands": ["test_command"]} - assert frontend["moduleFederation"] == {"exposes": ["./index"]} - assert frontend["remoteEntry"] == "remoteEntry.abc123.js" + assert manifest.frontend is not None + assert manifest.frontend.contributions.commands == [ + {"id": "test_command", "title": "Test"} + ] + assert manifest.frontend.moduleFederation.exposes == ["./index"] + assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js" # Verify backend section - assert "backend" in manifest - assert manifest["backend"]["entryPoints"] == ["test_extension.entrypoint"] + assert manifest.backend is not None + assert manifest.backend.entryPoints == ["test_extension.entrypoint"] @pytest.mark.unit @@ -280,14 +280,13 @@ def test_build_manifest_handles_minimal_extension(isolated_filesystem): manifest = build_manifest(isolated_filesystem, None) - manifest_dict = dict(manifest) - assert manifest_dict["id"] == "minimal_extension" - assert manifest_dict["name"] == "Minimal Extension" - assert manifest_dict["version"] == "0.1.0" - assert manifest_dict["permissions"] == [] - assert manifest_dict["dependencies"] == [] # Default empty list - assert "frontend" not in manifest - assert "backend" not in manifest + assert manifest.id == "minimal_extension" + assert manifest.name == "Minimal Extension" + assert manifest.version == "0.1.0" + assert manifest.permissions == [] + assert manifest.dependencies == [] # Default empty list + assert manifest.frontend is None + assert manifest.backend is None @pytest.mark.unit diff --git a/superset-extensions-cli/tests/test_cli_dev.py b/superset-extensions-cli/tests/test_cli_dev.py index 1c0d21cfeba..f75de6b2802 100644 --- a/superset-extensions-cli/tests/test_cli_dev.py +++ b/superset-extensions-cli/tests/test_cli_dev.py @@ -23,6 +23,7 @@ import time from unittest.mock import Mock, patch import pytest +from superset_core.extensions.types import Manifest from superset_extensions_cli.cli import app, FrontendChangeHandler @@ -48,7 +49,7 @@ def test_dev_command_starts_watchers( """Test dev command starts file watchers.""" # Setup mocks mock_rebuild_frontend.return_value = "remoteEntry.abc123.js" - mock_build_manifest.return_value = {"name": "test", "version": "1.0.0"} + mock_build_manifest.return_value = Manifest(id="test", name="test", version="1.0.0") mock_observer = Mock() mock_observer_class.return_value = mock_observer @@ -100,7 +101,7 @@ def test_dev_command_initial_build( """Test dev command performs initial build setup.""" # Setup mocks mock_rebuild_frontend.return_value = "remoteEntry.abc123.js" - mock_build_manifest.return_value = {"name": "test", "version": "1.0.0"} + mock_build_manifest.return_value = Manifest(id="test", name="test", version="1.0.0") extension_setup_for_dev(isolated_filesystem) @@ -188,11 +189,12 @@ def test_frontend_watcher_function_coverage(isolated_filesystem): dist_dir = isolated_filesystem / "dist" dist_dir.mkdir() + mock_manifest = Manifest(id="test", name="test", version="1.0.0") with patch("superset_extensions_cli.cli.rebuild_frontend") as mock_rebuild: with patch("superset_extensions_cli.cli.build_manifest") as mock_build: with patch("superset_extensions_cli.cli.write_manifest") as mock_write: mock_rebuild.return_value = "remoteEntry.abc123.js" - mock_build.return_value = {"name": "test", "version": "1.0.0"} + mock_build.return_value = mock_manifest # Simulate frontend watcher function logic frontend_dir = isolated_filesystem / "frontend" @@ -209,9 +211,7 @@ def test_frontend_watcher_function_coverage(isolated_filesystem): mock_build.assert_called_once_with( isolated_filesystem, "remoteEntry.abc123.js" ) - mock_write.assert_called_once_with( - isolated_filesystem, {"name": "test", "version": "1.0.0"} - ) + mock_write.assert_called_once_with(isolated_filesystem, mock_manifest) @pytest.mark.unit diff --git a/superset/extensions/discovery.py b/superset/extensions/discovery.py index 7a4d58c927c..5727a9d14b9 100644 --- a/superset/extensions/discovery.py +++ b/superset/extensions/discovery.py @@ -61,9 +61,8 @@ def discover_and_load_extensions( with ZipFile(supx_file, "r") as zip_file: files = get_bundle_files_from_zip(zip_file) extension = get_loaded_extension(files) - extension_id = extension.manifest["id"] logger.info( - "Loaded extension '%s' from %s", extension_id, supx_file + "Loaded extension '%s' from %s", extension.id, supx_file ) yield extension except Exception as e: diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py index e288287d229..d885676d4dd 100644 --- a/superset/extensions/utils.py +++ b/superset/extensions/utils.py @@ -26,6 +26,7 @@ from typing import Any, Generator, Iterable, Tuple from zipfile import ZipFile from flask import current_app +from pydantic import ValidationError from superset_core.extensions.types import Manifest from superset.extensions.types import BundleFile, LoadedExtension @@ -121,7 +122,7 @@ def get_bundle_files_from_path(base_path: str) -> Generator[BundleFile, None, No def get_loaded_extension(files: Iterable[BundleFile]) -> LoadedExtension: - manifest: Manifest = {} + manifest: Manifest | None = None frontend: dict[str, bytes] = {} backend: dict[str, bytes] = {} @@ -131,13 +132,12 @@ def get_loaded_extension(files: Iterable[BundleFile]) -> LoadedExtension: if filename == "manifest.json": try: - manifest = json.loads(content) - if "id" not in manifest: - raise Exception("Missing 'id' in manifest") - if "name" not in manifest: - raise Exception("Missing 'name' in manifest") + manifest_data = json.loads(content) + manifest = Manifest.model_validate(manifest_data) + except ValidationError as e: + raise Exception(f"Invalid manifest.json: {e}") from e except Exception as e: - raise Exception("Invalid manifest.json: %s" % e) from e + raise Exception(f"Failed to parse manifest.json: {e}") from e elif (match := FRONTEND_REGEX.match(filename)) is not None: frontend[match.group(1)] = content @@ -146,40 +146,39 @@ def get_loaded_extension(files: Iterable[BundleFile]) -> LoadedExtension: backend[match.group(1)] = content else: - raise Exception("Unexpected file in bundle: %s" % filename) + raise Exception(f"Unexpected file in bundle: {filename}") + + if manifest is None: + raise Exception("Missing manifest.json in extension bundle") - id_ = manifest["id"] - name = manifest["name"] - version = manifest["version"] return LoadedExtension( - id=id_, - name=name, + id=manifest.id, + name=manifest.name, manifest=manifest, frontend=frontend, backend=backend, - version=version, + version=manifest.version, ) def build_extension_data(extension: LoadedExtension) -> dict[str, Any]: - manifest: Manifest = extension.manifest + manifest = extension.manifest extension_data: dict[str, Any] = { - "id": manifest["id"], + "id": manifest.id, "name": extension.name, "version": extension.version, - "description": manifest.get("description", ""), - "dependencies": manifest.get("dependencies", []), - "extensionDependencies": manifest.get("extensionDependencies", []), + "description": manifest.description or "", + "dependencies": manifest.dependencies, } - if frontend := manifest.get("frontend"): - module_federation = frontend.get("moduleFederation", {}) - remote_entry = frontend["remoteEntry"] + if manifest.frontend: + frontend = manifest.frontend + module_federation = frontend.moduleFederation + remote_entry_url = f"/api/v1/extensions/{manifest.id}/{frontend.remoteEntry}" extension_data.update( { - "remoteEntry": "/api/v1/extensions/%s/%s" - % (manifest["id"], remote_entry), # noqa: E501 - "exposedModules": module_federation.get("exposes", []), - "contributions": frontend.get("contributions", {}), + "remoteEntry": remote_entry_url, + "exposedModules": module_federation.exposes, + "contributions": frontend.contributions.model_dump(), } ) return extension_data @@ -192,7 +191,7 @@ def get_extensions() -> dict[str, LoadedExtension]: for path in current_app.config["LOCAL_EXTENSIONS"]: files = get_bundle_files_from_path(path) extension = get_loaded_extension(files) - extension_id = extension.manifest["id"] + extension_id = extension.manifest.id extensions[extension_id] = extension logger.info( "Loading extension %s (ID: %s) from local filesystem", @@ -205,7 +204,7 @@ def get_extensions() -> dict[str, LoadedExtension]: from superset.extensions.discovery import discover_and_load_extensions for extension in discover_and_load_extensions(extensions_path): - extension_id = extension.manifest["id"] + extension_id = extension.manifest.id if extension_id not in extensions: # Don't override LOCAL_EXTENSIONS extensions[extension_id] = extension logger.info( diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 4f4fd361a49..1f18f7da0cd 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -564,9 +564,9 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods if backend_files := extension.backend: install_in_memory_importer(backend_files) - backend = extension.manifest.get("backend") + backend = extension.manifest.backend - if backend and (entrypoints := backend.get("entryPoints")): + if backend and (entrypoints := backend.entryPoints): for entrypoint in entrypoints: try: eager_import(entrypoint) diff --git a/tests/unit_tests/extensions/test_types.py b/tests/unit_tests/extensions/test_types.py new file mode 100644 index 00000000000..44137a83f1d --- /dev/null +++ b/tests/unit_tests/extensions/test_types.py @@ -0,0 +1,258 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for extension configuration and manifest Pydantic models.""" + +import pytest +from pydantic import ValidationError +from superset_core.extensions.types import ( + ContributionConfig, + ExtensionConfig, + ExtensionConfigBackend, + ExtensionConfigFrontend, + Manifest, + ManifestBackend, + ModuleFederationConfig, +) + +# ============================================================================= +# ExtensionConfig (extension.json) tests +# ============================================================================= + + +def test_extension_config_minimal(): + """Test ExtensionConfig with minimal required fields.""" + config = ExtensionConfig.model_validate( + { + "id": "my-extension", + "name": "My Extension", + } + ) + assert config.id == "my-extension" + assert config.name == "My Extension" + assert config.version == "0.0.0" + assert config.dependencies == [] + assert config.permissions == [] + assert config.frontend is None + assert config.backend is None + + +def test_extension_config_full(): + """Test ExtensionConfig with all fields populated.""" + config = ExtensionConfig.model_validate( + { + "id": "query_insights", + "name": "Query Insights", + "version": "1.0.0", + "license": "Apache-2.0", + "description": "A query insights extension", + "dependencies": ["other-extension"], + "permissions": ["can_read", "can_view"], + "frontend": { + "contributions": { + "views": { + "sqllab.panels": [ + {"id": "query_insights.main", "name": "Query Insights"} + ] + } + }, + "moduleFederation": {"exposes": ["./index"]}, + }, + "backend": { + "entryPoints": ["query_insights.entrypoint"], + "files": ["backend/src/query_insights/**/*.py"], + }, + } + ) + assert config.id == "query_insights" + assert config.name == "Query Insights" + assert config.version == "1.0.0" + assert config.license == "Apache-2.0" + assert config.description == "A query insights extension" + assert config.dependencies == ["other-extension"] + assert config.permissions == ["can_read", "can_view"] + assert config.frontend is not None + assert config.frontend.moduleFederation.exposes == ["./index"] + assert config.backend is not None + assert config.backend.entryPoints == ["query_insights.entrypoint"] + assert config.backend.files == ["backend/src/query_insights/**/*.py"] + + +def test_extension_config_missing_id(): + """Test ExtensionConfig raises error when id is missing.""" + with pytest.raises(ValidationError) as exc_info: + ExtensionConfig.model_validate({"name": "My Extension"}) + assert "id" in str(exc_info.value) + + +def test_extension_config_missing_name(): + """Test ExtensionConfig raises error when name is missing.""" + with pytest.raises(ValidationError) as exc_info: + ExtensionConfig.model_validate({"id": "my-extension"}) + assert "name" in str(exc_info.value) + + +def test_extension_config_empty_id(): + """Test ExtensionConfig raises error when id is empty.""" + with pytest.raises(ValidationError) as exc_info: + ExtensionConfig.model_validate({"id": "", "name": "My Extension"}) + assert "id" in str(exc_info.value) + + +def test_extension_config_invalid_version(): + """Test ExtensionConfig raises error for invalid version format.""" + with pytest.raises(ValidationError) as exc_info: + ExtensionConfig.model_validate( + {"id": "my-extension", "name": "My Extension", "version": "invalid"} + ) + assert "version" in str(exc_info.value) + + +def test_extension_config_valid_versions(): + """Test ExtensionConfig accepts valid semantic versions (major.minor.patch only).""" + for version in ["1.0.0", "0.1.0", "10.20.30"]: + config = ExtensionConfig.model_validate( + {"id": "my-extension", "name": "My Extension", "version": version} + ) + assert config.version == version + + +def test_extension_config_prerelease_version_rejected(): + """Test ExtensionConfig rejects prerelease versions.""" + with pytest.raises(ValidationError) as exc_info: + ExtensionConfig.model_validate( + {"id": "my-extension", "name": "My Extension", "version": "1.0.0-beta"} + ) + assert "version" in str(exc_info.value) + + +# ============================================================================= +# Manifest (manifest.json) tests +# ============================================================================= + + +def test_manifest_minimal(): + """Test Manifest with minimal required fields.""" + manifest = Manifest.model_validate( + { + "id": "my-extension", + "name": "My Extension", + } + ) + assert manifest.id == "my-extension" + assert manifest.name == "My Extension" + assert manifest.frontend is None + assert manifest.backend is None + + +def test_manifest_with_frontend(): + """Test Manifest with frontend section requires remoteEntry.""" + manifest = Manifest.model_validate( + { + "id": "my-extension", + "name": "My Extension", + "frontend": { + "remoteEntry": "remoteEntry.abc123.js", + "contributions": {}, + "moduleFederation": {"exposes": ["./index"]}, + }, + } + ) + assert manifest.frontend is not None + assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js" + assert manifest.frontend.moduleFederation.exposes == ["./index"] + + +def test_manifest_frontend_missing_remote_entry(): + """Test Manifest raises error when frontend is missing remoteEntry.""" + with pytest.raises(ValidationError) as exc_info: + Manifest.model_validate( + { + "id": "my-extension", + "name": "My Extension", + "frontend": {"contributions": {}, "moduleFederation": {}}, + } + ) + assert "remoteEntry" in str(exc_info.value) + + +def test_manifest_with_backend(): + """Test Manifest with backend section.""" + manifest = Manifest.model_validate( + { + "id": "my-extension", + "name": "My Extension", + "backend": {"entryPoints": ["my_extension.entrypoint"]}, + } + ) + assert manifest.backend is not None + assert manifest.backend.entryPoints == ["my_extension.entrypoint"] + + +def test_manifest_backend_no_files_field(): + """Test ManifestBackend does not have files field (only in ExtensionConfig).""" + manifest = Manifest.model_validate( + { + "id": "my-extension", + "name": "My Extension", + "backend": {"entryPoints": ["my_extension.entrypoint"]}, + } + ) + # ManifestBackend should not have a 'files' field + assert not hasattr(manifest.backend, "files") + + +# ============================================================================= +# Shared component tests +# ============================================================================= + + +def test_module_federation_config_defaults(): + """Test ModuleFederationConfig has correct defaults.""" + config = ModuleFederationConfig.model_validate({}) + assert config.exposes == [] + assert config.filename == "remoteEntry.js" + assert config.shared == {} + assert config.remotes == {} + + +def test_contribution_config_defaults(): + """Test ContributionConfig has correct defaults.""" + config = ContributionConfig.model_validate({}) + assert config.commands == [] + assert config.views == {} + assert config.menus == {} + + +def test_extension_config_frontend_defaults(): + """Test ExtensionConfigFrontend has correct defaults.""" + frontend = ExtensionConfigFrontend.model_validate({}) + assert frontend.contributions.commands == [] + assert frontend.moduleFederation.exposes == [] + + +def test_extension_config_backend_defaults(): + """Test ExtensionConfigBackend has correct defaults.""" + backend = ExtensionConfigBackend.model_validate({}) + assert backend.entryPoints == [] + assert backend.files == [] + + +def test_manifest_backend_defaults(): + """Test ManifestBackend has correct defaults.""" + backend = ManifestBackend.model_validate({}) + assert backend.entryPoints == []