# 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() # Create conventional frontend entry point frontend_src_dir = frontend_dir / "src" frontend_src_dir.mkdir() (frontend_src_dir / "index.tsx").write_text("// Frontend entry point") if include_backend: backend_dir = base_path / "backend" backend_dir.mkdir() # Create conventional backend structure backend_src_dir = ( backend_dir / "src" / "superset_extensions" / "test_org" / "test_extension" ) backend_src_dir.mkdir(parents=True) # Create conventional entry point file (backend_src_dir / "entrypoint.py").write_text("# Backend entry point") (backend_src_dir / "__init__.py").write_text("") # Create parent __init__.py files for namespace packages (backend_dir / "src" / "superset_extensions" / "__init__.py").write_text("") ( backend_dir / "src" / "superset_extensions" / "test_org" / "__init__.py" ).write_text("") # Create pyproject.toml matching the template structure pyproject_content = """[project] name = "test_org-test_extension" version = "1.0.0" license = "Apache-2.0" [tool.apache_superset_extensions.build] # Files to include in the extension build/bundle include = [ "src/superset_extensions/test_org/test_extension/**/*.py", ] exclude = [] """ (backend_dir / "pyproject.toml").write_text(pyproject_content) # Create extension.json extension_json = { "publisher": "test-org", "name": "test-extension", "displayName": "Test Extension", "version": "1.0.0", "permissions": [], } (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"}, "tool": { "apache_superset_extensions": { "build": { "include": [ "src/superset_extensions/test_org/test_extension/**/*.py" ] } } }, } # 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") @patch("superset_extensions_cli.cli.read_toml") def test_build_command_handles_frontend_build_failure( mock_read_toml, 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 mock_read_toml.return_value = { "project": {"name": "test"}, "tool": { "apache_superset_extensions": { "build": { "include": [ "src/superset_extensions/test_org/test_extension/**/*.py" ] } } }, } # 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, extension_with_build_structure ): """Test build_manifest creates correct manifest from extension.json.""" # Create extension structure with both frontend and backend extension_with_build_structure( isolated_filesystem, include_frontend=True, include_backend=True ) # Update extension.json with additional fields extension_data = { "publisher": "test-org", "name": "test-extension", "displayName": "Test Extension", "version": "1.0.0", "permissions": ["read_data"], "dependencies": ["some_dep"], } 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-org.test-extension" # Composite ID assert manifest.publisher == "test-org" assert manifest.name == "test-extension" assert manifest.displayName == "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.remoteEntry == "remoteEntry.abc123.js" assert manifest.frontend.moduleFederationName == "testOrg_testExtension" # Verify backend section and conventional entrypoint assert manifest.backend is not None assert ( manifest.backend.entrypoint == "superset_extensions.test_org.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 = { "publisher": "minimal-org", "name": "minimal-extension", "displayName": "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-org.minimal-extension" # Composite ID assert manifest.publisher == "minimal-org" assert manifest.name == "minimal-extension" assert manifest.displayName == "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 = { "publisher": "test-org", "name": "test-extension", "displayName": "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_dir = isolated_filesystem / "backend" backend_src = backend_dir / "src" / "superset_extensions" / "test_org" / "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 pyproject.toml with build configuration pyproject_content = """[project] name = "test_org-test_ext" version = "1.0.0" license = "Apache-2.0" [tool.apache_superset_extensions.build] include = [ "src/superset_extensions/test_org/test_ext/**/*", ] exclude = [] """ (backend_dir / "pyproject.toml").write_text(pyproject_content) # Create extension.json extension_data = { "publisher": "test-org", "name": "test-ext", "displayName": "Test Extension", "version": "1.0.0", "permissions": [], } (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" / "superset_extensions" / "test_org" / "test_ext" / "__init__.py" ) # Directory should not be copied as a file copied_subdir = ( dist_dir / "backend" / "src" / "superset_extensions" / "test_org" / "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 pyproject.toml.""" # Create backend source files backend_dir = isolated_filesystem / "backend" backend_src = backend_dir / "src" / "superset_extensions" / "test_org" / "test_ext" backend_src.mkdir(parents=True) (backend_src / "__init__.py").write_text("# init") (backend_src / "main.py").write_text("# main") # Create pyproject.toml with build configuration pyproject_content = """[project] name = "test_org-test_ext" version = "1.0.0" license = "Apache-2.0" [tool.apache_superset_extensions.build] include = [ "src/superset_extensions/test_org/test_ext/**/*.py", ] exclude = [] """ (backend_dir / "pyproject.toml").write_text(pyproject_content) # Create extension.json extension_data = { "publisher": "test-org", "name": "test-ext", "displayName": "Test Extension", "version": "1.0.0", "permissions": [], } (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" / "superset_extensions" / "test_org" / "test_ext" / "__init__.py" ) assert_file_exists( dist_dir / "backend" / "src" / "superset_extensions" / "test_org" / "test_ext" / "main.py" ) @pytest.mark.unit def test_copy_backend_files_handles_various_glob_patterns(isolated_filesystem): """Test copy_backend_files correctly handles different glob pattern formats.""" # Create backend structure with files in different locations backend_dir = isolated_filesystem / "backend" backend_src = backend_dir / "src" / "superset_extensions" / "test_org" / "test_ext" backend_src.mkdir(parents=True) # Create files that should match different pattern types (backend_src / "__init__.py").write_text("# init") (backend_src / "main.py").write_text("# main") (backend_dir / "config.py").write_text("# config") # Root level file # Create subdirectory with files subdir = backend_src / "utils" subdir.mkdir() (subdir / "helper.py").write_text("# helper") # Create pyproject.toml with various glob patterns that would fail with old logic pyproject_content = """[project] name = "test_org-test_ext" version = "1.0.0" license = "Apache-2.0" [tool.apache_superset_extensions.build] include = [ "config.py", # No '/' - would break old logic "**/*.py", # Starts with '**' - would break old logic "src/superset_extensions/test_org/test_ext/main.py", # Specific file ] exclude = [] """ (backend_dir / "pyproject.toml").write_text(pyproject_content) # Create extension.json extension_data = { "publisher": "test-org", "name": "test-ext", "displayName": "Test Extension", "version": "1.0.0", "permissions": [], } (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 according to patterns dist_dir = isolated_filesystem / "dist" # config.py (pattern: "config.py") assert_file_exists(dist_dir / "backend" / "config.py") # All .py files should be included (pattern: "**/*.py") assert_file_exists( dist_dir / "backend" / "src" / "superset_extensions" / "test_org" / "test_ext" / "__init__.py" ) assert_file_exists( dist_dir / "backend" / "src" / "superset_extensions" / "test_org" / "test_ext" / "utils" / "helper.py" ) # Specific file (pattern: "src/superset_extensions/test_org/test_ext/main.py") assert_file_exists( dist_dir / "backend" / "src" / "superset_extensions" / "test_org" / "test_ext" / "main.py" ) # Removed obsolete tests: # - test_copy_backend_files_handles_no_backend_config: This scenario can't happen since copy_backend_files is only called when backend exists # - test_copy_backend_files_exits_when_extension_json_missing: Validation catches this before copy_backend_files is called # 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