blob: 9f4e70f8b11d216bd07e8544994a6380e1c548bd [file] [log] [blame]
# 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": ["test_command"]},
"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
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"]
# 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"
# Verify backend section
assert "backend" in manifest
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)
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
@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