fix(extensions-cli): fail build and bundle when the frontend build fails (#44407)

Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
This commit is contained in:
Mike Bridge
2026-09-21 19:29:16 -06:00
committed by GitHub
parent 7eb9532c0b
commit 770bcfb2e9
3 changed files with 120 additions and 19 deletions

View File

@@ -62,6 +62,11 @@ superset-extensions bundle [--output/-o <path>]
superset-extensions dev
```
If a frontend build fails, `build` and `bundle` exit non-zero without writing
a new manifest or bundle. An existing bundle is not removed; callers must check
the exit status before using it. In `dev` mode, failed frontend rebuilds are
reported and the watcher remains available for subsequent changes.
## 📋 Extension Structure
The CLI scaffolds extensions with the following structure:

View File

@@ -565,6 +565,9 @@ def build(ctx: click.Context) -> None:
init_frontend_deps(frontend_dir)
remote_entry = rebuild_frontend(cwd, frontend_dir)
if remote_entry is None:
sys.exit(1)
# Build backend independently if it exists
if backend_dir.exists():
pyproject = read_toml(backend_dir / "pyproject.toml")

View File

@@ -18,10 +18,14 @@
from __future__ import annotations
import json
import zipfile
from collections.abc import Callable
from pathlib import Path
from unittest.mock import Mock, patch
import click
import pytest
from click.testing import CliRunner, Result
from superset_extensions_cli.cli import (
app,
build_manifest,
@@ -146,20 +150,22 @@ def test_build_command_success_flow(
@pytest.mark.cli
@pytest.mark.parametrize("command", ["build", "bundle"])
@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."""
mock_read_toml: Mock,
mock_rebuild_frontend: Mock,
mock_init_frontend_deps: Mock,
mock_validate_npm: Mock,
cli_runner: CliRunner,
isolated_filesystem: Path,
extension_with_build_structure: Callable[..., dict[str, Path | None]],
command: str,
) -> None:
"""A failed frontend prevents manifest publication and archive creation."""
# Setup mocks
mock_rebuild_frontend.return_value = None # Indicates failure
mock_read_toml.return_value = {
@@ -174,11 +180,97 @@ def test_build_command_handles_frontend_build_failure(
# Create extension structure
extension_with_build_structure(isolated_filesystem)
result = cli_runner.invoke(app, ["build"])
result: Result = cli_runner.invoke(app, [command])
# Command should complete and create manifest even with frontend failure
assert result.exit_code == 1
assert "✅ Full build completed in dist/" not in result.output
assert "✅ Bundle created" not in result.output
assert not (isolated_filesystem / "dist" / "manifest.json").exists()
assert not list(isolated_filesystem.glob("*.supx"))
@pytest.mark.cli
@pytest.mark.parametrize("command", ["build", "bundle"])
@pytest.mark.parametrize("return_code", [0, 1])
def test_commands_propagate_frontend_result(
cli_runner: CliRunner,
isolated_filesystem: Path,
extension_with_build_structure: Callable[..., dict[str, Path | None]],
command: str,
return_code: int,
) -> None:
"""Exercise the real rebuild, manifest and bundle paths around the compiler."""
extension_with_build_structure(isolated_filesystem, include_backend=False)
frontend_dist: Path = isolated_filesystem / "frontend" / "dist"
frontend_dist.mkdir()
(frontend_dist / "remoteEntry.abc123.js").write_text("// compiled entry")
archive: Path = isolated_filesystem / "test-extension-1.0.0.supx"
with (
patch("superset_extensions_cli.cli.validate_npm"),
patch("superset_extensions_cli.cli.init_frontend_deps"),
patch(
"superset_extensions_cli.cli.run_frontend_build",
return_value=Mock(returncode=return_code),
),
):
result: Result = cli_runner.invoke(app, [command])
assert result.exit_code == return_code
if return_code:
assert "❌ Frontend build failed" in result.output
assert "✅ Full build completed" not in result.output
assert "✅ Bundle created" not in result.output
assert not (isolated_filesystem / "dist" / "manifest.json").exists()
assert not archive.exists()
else:
assert "✅ Full build completed" in result.output
assert (isolated_filesystem / "dist" / "manifest.json").exists()
if command == "bundle":
with zipfile.ZipFile(archive) as bundle_file:
assert "frontend/dist/remoteEntry.abc123.js" in bundle_file.namelist()
@pytest.mark.cli
def test_dev_watcher_recovers_after_frontend_failure(
cli_runner: CliRunner,
isolated_filesystem: Path,
extension_with_build_structure: Callable[..., dict[str, Path | None]],
) -> None:
"""A failed watcher rebuild returns normally and a later success publishes."""
extension_with_build_structure(isolated_filesystem, include_backend=False)
frontend_dist: Path = isolated_filesystem / "frontend" / "dist"
frontend_dist.mkdir()
(frontend_dist / "remoteEntry.abc123.js").write_text("// compiled entry")
observer: Mock = Mock()
with (
patch("superset_extensions_cli.cli.Observer", return_value=observer),
patch("superset_extensions_cli.cli.init_frontend_deps"),
patch(
"superset_extensions_cli.cli.run_frontend_build",
side_effect=[Mock(returncode=0), Mock(returncode=1), Mock(returncode=0)],
) as compiler,
patch("superset_extensions_cli.cli.write_manifest") as write_manifest,
):
def rebuild_then_stop(_: float) -> None:
"""Drive the registered watcher synchronously without a live thread."""
trigger: Callable[[], None] = observer.schedule.call_args.args[
0
].trigger_build
trigger()
assert write_manifest.call_count == 1
trigger()
assert write_manifest.call_count == 2
raise KeyboardInterrupt
with patch(
"superset_extensions_cli.cli.time.sleep", side_effect=rebuild_then_stop
):
result: Result = cli_runner.invoke(app, ["dev"])
assert result.exit_code == 0
assert "✅ Full build completed in dist/" in result.output
assert "❌ Frontend build failed" in result.output
assert compiler.call_count == 3
observer.stop.assert_called_once()
observer.join.assert_called_once()
# Clean Dist Tests
@@ -401,32 +493,33 @@ def test_run_frontend_build_with_output_messages(isolated_filesystem):
[
(0, "remoteEntry.abc123.js"),
(1, None),
(2, None),
],
)
def test_rebuild_frontend_handles_build_results(
isolated_filesystem, return_code, expected_result
):
"""Test rebuild_frontend handles different build results."""
isolated_filesystem: Path, return_code: int, expected_result: str | None
) -> None:
"""Failed rebuilds return None rather than raising into the watcher."""
from superset_extensions_cli.cli import rebuild_frontend
# Create frontend structure
frontend_dir = isolated_filesystem / "frontend"
frontend_dir: Path = 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: Path = frontend_dir / "dist"
frontend_dist.mkdir()
(frontend_dist / "remoteEntry.abc123.js").write_text("content")
# Create dist directory
dist_dir = isolated_filesystem / "dist"
dist_dir: Path = 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)
result: str | None = rebuild_frontend(isolated_filesystem, frontend_dir)
assert result == expected_result