# 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. from __future__ import annotations import json from unittest.mock import Mock, patch import pytest from superset_extensions_cli.cli import ( app, build_manifest, clean_dist, copy_backend_files, copy_frontend_dist, init_frontend_deps, ) from tests.utils import ( assert_directory_exists, assert_file_exists, ) @pytest.fixture def extension_with_build_structure(): """Create extension structure suitable for build testing.""" def _create(base_path, include_frontend=True, include_backend=True): # Create required directories if include_frontend: frontend_dir = base_path / "frontend" frontend_dir.mkdir() if include_backend: backend_dir = base_path / "backend" backend_dir.mkdir() # Create extension.json extension_json = { "id": "test_extension", "name": "Test Extension", "version": "1.0.0", "permissions": [], } if include_frontend: extension_json["frontend"] = { "contributions": {"commands": []}, "moduleFederation": {"exposes": ["./index"]}, } if include_backend: extension_json["backend"] = {"entryPoints": ["test_extension.entrypoint"]} (base_path / "extension.json").write_text(json.dumps(extension_json)) return { "frontend_dir": frontend_dir if include_frontend else None, "backend_dir": backend_dir if include_backend else None, } return _create # Build Command Tests @pytest.mark.cli @patch("superset_extensions_cli.cli.validate_npm") @patch("superset_extensions_cli.cli.init_frontend_deps") @patch("superset_extensions_cli.cli.rebuild_frontend") @patch("superset_extensions_cli.cli.rebuild_backend") @patch("superset_extensions_cli.cli.read_toml") def test_build_command_success_flow( mock_read_toml, mock_rebuild_backend, mock_rebuild_frontend, mock_init_frontend_deps, mock_validate_npm, cli_runner, isolated_filesystem, extension_with_build_structure, ): """Test build command success flow.""" # Setup mocks mock_rebuild_frontend.return_value = "remoteEntry.abc123.js" mock_read_toml.return_value = {"project": {"name": "test"}} # Create extension structure dirs = extension_with_build_structure(isolated_filesystem) result = cli_runner.invoke(app, ["build"]) assert result.exit_code == 0 assert "✅ Full build completed in dist/" in result.output # Verify function calls mock_validate_npm.assert_called_once() mock_init_frontend_deps.assert_called_once_with(dirs["frontend_dir"]) mock_rebuild_frontend.assert_called_once() mock_rebuild_backend.assert_called_once() @pytest.mark.cli @patch("superset_extensions_cli.cli.validate_npm") @patch("superset_extensions_cli.cli.init_frontend_deps") @patch("superset_extensions_cli.cli.rebuild_frontend") def test_build_command_handles_frontend_build_failure( mock_rebuild_frontend, mock_init_frontend_deps, mock_validate_npm, cli_runner, isolated_filesystem, extension_with_build_structure, ): """Test build command handles frontend build failure.""" # Setup mocks mock_rebuild_frontend.return_value = None # Indicates failure # Create extension structure extension_with_build_structure(isolated_filesystem) result = cli_runner.invoke(app, ["build"]) # Command should complete and create manifest even with frontend failure assert result.exit_code == 0 assert "✅ Full build completed in dist/" in result.output # Clean Dist Tests @pytest.mark.unit def test_clean_dist_removes_existing_dist_directory(isolated_filesystem): """Test clean_dist removes existing dist directory and recreates it.""" # Create dist directory with some content dist_dir = isolated_filesystem / "dist" dist_dir.mkdir() (dist_dir / "some_file.txt").write_text("test content") (dist_dir / "subdir").mkdir() clean_dist(isolated_filesystem) # Should exist but be empty assert_directory_exists(dist_dir) assert list(dist_dir.iterdir()) == [] @pytest.mark.unit def test_clean_dist_creates_dist_directory_if_missing(isolated_filesystem): """Test clean_dist creates dist directory when it doesn't exist.""" dist_dir = isolated_filesystem / "dist" assert not dist_dir.exists() clean_dist(isolated_filesystem) assert_directory_exists(dist_dir) # Frontend Dependencies Tests @pytest.mark.unit @patch("subprocess.run") def test_init_frontend_deps_skips_when_node_modules_exists( mock_run, isolated_filesystem ): """Test init_frontend_deps skips npm ci when node_modules exists.""" frontend_dir = isolated_filesystem / "frontend" frontend_dir.mkdir() (frontend_dir / "node_modules").mkdir() init_frontend_deps(frontend_dir) # Should not call subprocess.run for npm ci mock_run.assert_not_called() @pytest.mark.unit @patch("subprocess.run") @patch("superset_extensions_cli.cli.validate_npm") def test_init_frontend_deps_runs_npm_i_when_missing( mock_validate_npm, mock_run, isolated_filesystem ): """Test init_frontend_deps runs npm ci when node_modules is missing.""" frontend_dir = isolated_filesystem / "frontend" frontend_dir.mkdir() # Mock successful npm ci mock_run.return_value = Mock(returncode=0) init_frontend_deps(frontend_dir) # Should validate npm and run npm ci mock_validate_npm.assert_called_once() mock_run.assert_called_once_with(["npm", "i"], cwd=frontend_dir, text=True) @pytest.mark.unit @patch("subprocess.run") @patch("superset_extensions_cli.cli.validate_npm") def test_init_frontend_deps_exits_on_npm_ci_failure( mock_validate_npm, mock_run, isolated_filesystem ): """Test init_frontend_deps exits when npm ci fails.""" frontend_dir = isolated_filesystem / "frontend" frontend_dir.mkdir() # Mock failed npm ci mock_run.return_value = Mock(returncode=1) with pytest.raises(SystemExit) as exc_info: init_frontend_deps(frontend_dir) assert exc_info.value.code == 1 # Build Manifest Tests @pytest.mark.unit def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem): """Test build_manifest creates correct manifest from extension.json.""" # Create extension.json extension_data = { "id": "test_extension", "name": "Test Extension", "version": "1.0.0", "permissions": ["read_data"], "dependencies": ["some_dep"], "frontend": { "contributions": {"commands": [{"id": "test_command", "title": "Test"}]}, "moduleFederation": {"exposes": ["./index"]}, }, "backend": {"entryPoints": ["test_extension.entrypoint"]}, } extension_json = isolated_filesystem / "extension.json" extension_json.write_text(json.dumps(extension_data)) manifest = build_manifest(isolated_filesystem, "remoteEntry.abc123.js") # Verify manifest structure 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 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 manifest.backend is not None assert manifest.backend.entryPoints == ["test_extension.entrypoint"] @pytest.mark.unit def test_build_manifest_handles_minimal_extension(isolated_filesystem): """Test build_manifest with minimal extension.json (no frontend/backend).""" extension_data = { "id": "minimal_extension", "name": "Minimal Extension", "version": "0.1.0", "permissions": [], } extension_json = isolated_filesystem / "extension.json" extension_json.write_text(json.dumps(extension_data)) manifest = build_manifest(isolated_filesystem, None) 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 def test_build_manifest_exits_when_extension_json_missing(isolated_filesystem): """Test build_manifest exits when extension.json is missing.""" with pytest.raises(SystemExit) as exc_info: build_manifest(isolated_filesystem, "remoteEntry.js") assert exc_info.value.code == 1 # Frontend Build Tests @pytest.mark.unit def test_clean_dist_frontend_removes_frontend_dist(isolated_filesystem): """Test clean_dist_frontend removes frontend/dist directory specifically.""" from superset_extensions_cli.cli import clean_dist_frontend # Create dist/frontend structure dist_dir = isolated_filesystem / "dist" dist_dir.mkdir(parents=True) frontend_dist = dist_dir / "frontend" frontend_dist.mkdir() (frontend_dist / "some_file.js").write_text("content") clean_dist_frontend(isolated_filesystem) # Frontend dist should be removed, but dist should remain assert dist_dir.exists() assert not frontend_dist.exists() @pytest.mark.unit def test_clean_dist_frontend_handles_nonexistent_directory(isolated_filesystem): """Test clean_dist_frontend handles case where frontend dist doesn't exist.""" from superset_extensions_cli.cli import clean_dist_frontend # No dist directory exists clean_dist_frontend(isolated_filesystem) # Should not raise error @pytest.mark.unit def test_run_frontend_build_with_output_messages(isolated_filesystem): """Test run_frontend_build produces expected output messages.""" from superset_extensions_cli.cli import run_frontend_build frontend_dir = isolated_filesystem / "frontend" frontend_dir.mkdir() with patch("subprocess.run") as mock_run: mock_result = Mock(returncode=0) mock_run.return_value = mock_result result = run_frontend_build(frontend_dir) assert result.returncode == 0 mock_run.assert_called_once_with( ["npm", "run", "build"], cwd=frontend_dir, text=True ) @pytest.mark.unit @pytest.mark.parametrize( "return_code,expected_result", [ (0, "remoteEntry.abc123.js"), (1, None), ], ) def test_rebuild_frontend_handles_build_results( isolated_filesystem, return_code, expected_result ): """Test rebuild_frontend handles different build results.""" from superset_extensions_cli.cli import rebuild_frontend # Create frontend structure frontend_dir = isolated_filesystem / "frontend" frontend_dir.mkdir() if return_code == 0: # Create frontend/dist with remoteEntry for success case frontend_dist = frontend_dir / "dist" frontend_dist.mkdir() (frontend_dist / "remoteEntry.abc123.js").write_text("content") # Create dist directory dist_dir = isolated_filesystem / "dist" dist_dir.mkdir() with patch("superset_extensions_cli.cli.run_frontend_build") as mock_build: mock_build.return_value = Mock(returncode=return_code) result = rebuild_frontend(isolated_filesystem, frontend_dir) assert result == expected_result # Backend Build Tests @pytest.mark.unit def test_rebuild_backend_calls_copy_and_shows_message(isolated_filesystem): """Test rebuild_backend calls copy_backend_files and shows success message.""" from superset_extensions_cli.cli import rebuild_backend # Create extension.json extension_json = { "id": "test", "name": "Test Extension", "version": "1.0.0", "permissions": [], } (isolated_filesystem / "extension.json").write_text(json.dumps(extension_json)) with patch("superset_extensions_cli.cli.copy_backend_files") as mock_copy: rebuild_backend(isolated_filesystem) mock_copy.assert_called_once_with(isolated_filesystem) @pytest.mark.unit def test_copy_backend_files_skips_non_files(isolated_filesystem): """Test copy_backend_files skips directories and non-files.""" # Create backend structure with directory backend_src = isolated_filesystem / "backend" / "src" / "test_ext" backend_src.mkdir(parents=True) (backend_src / "__init__.py").write_text("# init") # Create a subdirectory (should be skipped) subdir = backend_src / "subdir" subdir.mkdir() # Create extension.json with backend file patterns extension_data = { "id": "test_ext", "name": "Test Extension", "version": "1.0.0", "permissions": [], "backend": { "files": ["backend/src/test_ext/**/*"] # Will match both files and dirs }, } (isolated_filesystem / "extension.json").write_text(json.dumps(extension_data)) # Create dist directory clean_dist(isolated_filesystem) copy_backend_files(isolated_filesystem) # Verify only files were copied, not directories dist_dir = isolated_filesystem / "dist" assert_file_exists(dist_dir / "backend" / "src" / "test_ext" / "__init__.py") # Directory should not be copied as a file copied_subdir = dist_dir / "backend" / "src" / "test_ext" / "subdir" # The directory might exist but should be empty since we skip non-files if copied_subdir.exists(): assert list(copied_subdir.iterdir()) == [] @pytest.mark.unit def test_copy_backend_files_copies_matched_files(isolated_filesystem): """Test copy_backend_files copies files matching patterns from extension.json.""" # Create backend source files backend_src = isolated_filesystem / "backend" / "src" / "test_ext" backend_src.mkdir(parents=True) (backend_src / "__init__.py").write_text("# init") (backend_src / "main.py").write_text("# main") # Create extension.json with backend file patterns extension_data = { "id": "test_ext", "name": "Test Extension", "version": "1.0.0", "permissions": [], "backend": {"files": ["backend/src/test_ext/**/*.py"]}, } (isolated_filesystem / "extension.json").write_text(json.dumps(extension_data)) # Create dist directory clean_dist(isolated_filesystem) copy_backend_files(isolated_filesystem) # Verify files were copied dist_dir = isolated_filesystem / "dist" assert_file_exists(dist_dir / "backend" / "src" / "test_ext" / "__init__.py") assert_file_exists(dist_dir / "backend" / "src" / "test_ext" / "main.py") @pytest.mark.unit def test_copy_backend_files_handles_no_backend_config(isolated_filesystem): """Test copy_backend_files handles extension.json without backend config.""" extension_data = { "id": "frontend_only", "name": "Frontend Only Extension", "version": "1.0.0", "permissions": [], } (isolated_filesystem / "extension.json").write_text(json.dumps(extension_data)) clean_dist(isolated_filesystem) # Should not raise error copy_backend_files(isolated_filesystem) @pytest.mark.unit def test_copy_backend_files_exits_when_extension_json_missing(isolated_filesystem): """Test copy_backend_files exits when extension.json is missing.""" clean_dist(isolated_filesystem) with pytest.raises(SystemExit) as exc_info: copy_backend_files(isolated_filesystem) assert exc_info.value.code == 1 # Frontend Dist Copy Tests @pytest.mark.unit def test_copy_frontend_dist_copies_files_correctly(isolated_filesystem): """Test copy_frontend_dist copies frontend build files to dist.""" # Create frontend/dist structure frontend_dist = isolated_filesystem / "frontend" / "dist" frontend_dist.mkdir(parents=True) # Create some files including remoteEntry (frontend_dist / "remoteEntry.abc123.js").write_text("remote entry content") (frontend_dist / "main.js").write_text("main js content") # Create subdirectory with file assets_dir = frontend_dist / "assets" assets_dir.mkdir() (assets_dir / "style.css").write_text("css content") # Create dist directory clean_dist(isolated_filesystem) remote_entry = copy_frontend_dist(isolated_filesystem) assert remote_entry == "remoteEntry.abc123.js" # Verify files were copied dist_dir = isolated_filesystem / "dist" assert_file_exists(dist_dir / "frontend" / "dist" / "remoteEntry.abc123.js") assert_file_exists(dist_dir / "frontend" / "dist" / "main.js") assert_file_exists(dist_dir / "frontend" / "dist" / "assets" / "style.css") @pytest.mark.unit def test_copy_frontend_dist_exits_when_no_remote_entry(isolated_filesystem): """Test copy_frontend_dist exits when no remoteEntry file found.""" # Create frontend/dist without remoteEntry file frontend_dist = isolated_filesystem / "frontend" / "dist" frontend_dist.mkdir(parents=True) (frontend_dist / "main.js").write_text("main content") clean_dist(isolated_filesystem) with pytest.raises(SystemExit) as exc_info: copy_frontend_dist(isolated_filesystem) assert exc_info.value.code == 1