From 76c5e367efd66d7d8ec352d97f2167f600e9e1fa Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 7 Sep 2026 13:47:14 -0400 Subject: [PATCH] fix(starlark): restore the Pixlet install and status routes "Pixlet install failed: Resource not found" is Flask's 404 handler. The install button posts to /api/v3/starlark/install-pixlet, that route does not exist, and app.py's generic 404 answers with {"status": "error", "message": "Resource not found"} which the button prints verbatim -- naming neither the resource nor the cause. The routes were added by #253 and removed by #330, which rewrote api_v3.py (3272 lines changed) and dropped all thirteen Starlark routes with it. Nothing else moved: the frontend still calls them and plugin-repos/starlark-apps still implements the work behind them, so every Starlark call in the UI has been landing on the 404 handler since. Restores the two the Pixlet flow needs -- install-pixlet, and the status call the button reloads afterwards -- verbatim from 1c4d5c52^, plus the three helpers they use (_get_starlark_plugin, _find_pixlet_binary, _read_starlark_manifest) and _STARLARK_APPS_DIR. The installer itself is fine; I checked rather than assumed. Against the live release, the naming the script builds matches what tronbyt/pixlet publishes, and the URL resolves: latest tag v0.53.1 asset published pixlet_v0.53.1_linux-arm64.tar.gz script would request pixlet_v0.53.1_linux-arm64.tar.gz HEAD 200 So the missing route was the whole fault. Deliberately scoped to the reported bug. The other eleven routes are still missing -- apps CRUD, config, toggle, render, repository browse and install, repository categories -- and the rest of the Starlark page is still dead. #463 covers those alongside behavioural changes; this stands alone so the install can be fixed without them. test_starlark_pixlet_routes.py asserts both routes are registered and answer in the shape the JS reads, including the not-yet-loaded-plugin case a first-time user is in when they press Install. 8 of its 9 tests fail against main. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .../test_starlark_pixlet_routes.py | 109 +++++++++++++ web_interface/blueprints/api_v3.py | 143 +++++++++++++++++- 2 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 test/web_interface/test_starlark_pixlet_routes.py diff --git a/test/web_interface/test_starlark_pixlet_routes.py b/test/web_interface/test_starlark_pixlet_routes.py new file mode 100644 index 00000000..8a82d7e7 --- /dev/null +++ b/test/web_interface/test_starlark_pixlet_routes.py @@ -0,0 +1,109 @@ +"""The Starlark routes the frontend calls must exist. + +`plugins_manager.js` posts to /api/v3/starlark/install-pixlet and then reloads +/api/v3/starlark/status. Neither route existed: #330 rewrote api_v3.py and +dropped all thirteen Starlark routes that #253 had added, so both calls fell +through to Flask's 404 handler, which answers + + {"status": "error", "message": "Resource not found"} + +and the button reported "Pixlet install failed: Resource not found" -- a +message that names neither the resource nor the cause. + +These assert the routes are registered and answer in the shape the frontend +reads, so a future rewrite of this file cannot silently drop them again. +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def client(): + from web_interface.app import app + app.config['TESTING'] = True + with app.test_client() as c: + yield c + + +class TestRoutesAreRegistered: + """The failure was a missing route, so check the URL map directly.""" + + @pytest.mark.parametrize("rule,method", [ + ("/api/v3/starlark/install-pixlet", "POST"), + ("/api/v3/starlark/status", "GET"), + ]) + def test_route_exists(self, rule, method): + from web_interface.app import app + matches = [r for r in app.url_map.iter_rules() + if r.rule == rule and method in r.methods] + assert matches, ( + f"{method} {rule} is not registered; the frontend calls it and " + "would get Flask's generic 'Resource not found'") + + +class TestInstallPixlet: + def test_it_does_not_404(self, client): + with patch('web_interface.blueprints.api_v3.subprocess.run') as run: + run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + resp = client.post('/api/v3/starlark/install-pixlet') + assert resp.status_code != 404, "the route is still missing" + assert resp.get_json().get('message') != 'Resource not found' + + def test_success_is_reported_in_the_shape_the_button_reads(self, client): + with patch('web_interface.blueprints.api_v3.subprocess.run') as run: + run.return_value = MagicMock(returncode=0, stdout="done", stderr="") + resp = client.post('/api/v3/starlark/install-pixlet') + body = resp.get_json() + assert body['status'] == 'success', body + assert 'message' in body, "the JS shows data.message on success" + + def test_a_failed_download_says_why(self, client): + with patch('web_interface.blueprints.api_v3.subprocess.run') as run: + run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release") + resp = client.post('/api/v3/starlark/install-pixlet') + body = resp.get_json() + assert body['status'] == 'error' + assert 'no such release' in body['message'], \ + "the installer's own stderr is what tells the user what went wrong" + + def test_a_timeout_is_reported_rather_than_hanging(self, client): + import subprocess as sp + with patch('web_interface.blueprints.api_v3.subprocess.run', + side_effect=sp.TimeoutExpired(cmd='x', timeout=300)): + resp = client.post('/api/v3/starlark/install-pixlet') + assert resp.get_json()['status'] == 'error' + assert 'timed out' in resp.get_json()['message'].lower() + + +class TestStarlarkStatus: + def test_it_does_not_404(self, client): + resp = client.get('/api/v3/starlark/status') + assert resp.status_code != 404, "the route is still missing" + assert resp.get_json().get('message') != 'Resource not found' + + def test_it_reports_pixlet_availability_without_the_plugin_loaded(self, client): + # The status call runs before install too -- it must answer even when + # starlark-apps is not loaded, which is the state a user is in when + # they press the install button for the first time. + with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None): + resp = client.get('/api/v3/starlark/status') + body = resp.get_json() + assert body['status'] == 'success', body + assert 'pixlet_available' in body + assert body['plugin_loaded'] is False + + +class TestTheInstallerScriptIsActuallyThere: + def test_download_pixlet_script_exists_and_is_executable(self): + # install_pixlet chmods and runs this; a missing file is the one error + # it reports as a 404 of its own, which would look identical to the + # bug being fixed here. + import os + from pathlib import Path + from web_interface.blueprints.api_v3 import PROJECT_ROOT + script = Path(PROJECT_ROOT) / 'scripts' / 'download_pixlet.sh' + assert script.is_file(), f"{script} is missing; install_pixlet would 404" + assert os.access(script, os.R_OK) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 35dfd885..74e940e3 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -8654,4 +8654,145 @@ def backup_delete(filename): except OSError as e: logger.error("backup_delete failed: %s", e, exc_info=True) return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500 - return jsonify({'status': 'error', 'message': 'Backup not found'}), 404 \ No newline at end of file + return jsonify({'status': 'error', 'message': 'Backup not found'}), 404 + + +# ── Starlark / Pixlet ──────────────────────────────────────────────────────── +# These routes were added by #253 and removed by #330, which rewrote this file +# and dropped all thirteen of them. Nothing else changed: the frontend still +# calls them and plugin-repos/starlark-apps still implements the work behind +# them, so every call has been landing on Flask's 404 handler and coming back +# as the generic "Resource not found" -- which is what the Pixlet install +# button reports. +# +# Restored verbatim from 1c4d5c52^ (the commit before the removal). The other +# eleven are still missing; see the PR description. + +_STARLARK_APPS_DIR = PROJECT_ROOT / 'starlark-apps' +_STARLARK_MANIFEST_FILE = _STARLARK_APPS_DIR / 'manifest.json' + +def _get_starlark_plugin() -> Optional[Any]: + """Get the starlark-apps plugin instance, or None.""" + if not api_v3.plugin_manager: + return None + return api_v3.plugin_manager.get_plugin('starlark-apps') + +def _find_pixlet_binary(explicit_path: Optional[str] = None) -> Optional[str]: + """Find pixlet binary: explicit path → bundled binary → system PATH.""" + import platform + if explicit_path and os.path.isfile(explicit_path) and os.access(explicit_path, os.X_OK): + return explicit_path + bin_dir = PROJECT_ROOT / "bin" / "pixlet" + system = platform.system().lower() + machine = platform.machine().lower() + if system == "linux": + if "aarch64" in machine or "arm64" in machine: + name = "pixlet-linux-arm64" + elif "x86_64" in machine or "amd64" in machine: + name = "pixlet-linux-amd64" + else: + name = None + elif system == "darwin": + name = "pixlet-darwin-arm64" if "arm64" in machine else "pixlet-darwin-amd64" + else: + name = None + if name: + bundled = bin_dir / name + if bundled.is_file(): + if os.access(str(bundled), os.X_OK): + return str(bundled) + try: + bundled.chmod(0o755) + except OSError: + logger.warning("Could not make pixlet bundled binary executable (%s); falling back to PATH", bundled) + else: + if os.access(str(bundled), os.X_OK): + return str(bundled) + logger.warning("Pixlet bundled binary still not executable after chmod (%s); falling back to PATH", bundled) + return shutil.which("pixlet") + +def _read_starlark_manifest() -> Dict[str, Any]: + """Read the starlark-apps manifest.json directly from disk.""" + try: + if _STARLARK_MANIFEST_FILE.exists(): + with open(_STARLARK_MANIFEST_FILE, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.error(f"Error reading starlark manifest: {e}") + return {'apps': {}} + +@api_v3.route('/starlark/status', methods=['GET']) +def get_starlark_status(): + """Get Starlark plugin status and Pixlet availability.""" + try: + starlark_plugin = _get_starlark_plugin() + if starlark_plugin: + info = starlark_plugin.get_info() + magnify_info = starlark_plugin.get_magnify_recommendation() + return jsonify({ + 'status': 'success', + 'pixlet_available': info.get('pixlet_available', False), + 'pixlet_version': info.get('pixlet_version'), + 'installed_apps': info.get('installed_apps', 0), + 'enabled_apps': info.get('enabled_apps', 0), + 'current_app': info.get('current_app'), + 'plugin_enabled': starlark_plugin.enabled, + 'display_info': magnify_info + }) + + # Plugin not loaded - check Pixlet availability via shared resolver + # (respects user-configured pixlet_path, bundled binary, and system PATH) + full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + pixlet_path = _find_pixlet_binary(full_config.get('starlark-apps', {}).get('pixlet_path')) + pixlet_available = pixlet_path is not None + + # Read app counts from manifest + manifest = _read_starlark_manifest() + apps = manifest.get('apps', {}) + installed_count = len(apps) + enabled_count = sum(1 for a in apps.values() if a.get('enabled', True)) + + return jsonify({ + 'status': 'success', + 'pixlet_available': pixlet_available, + 'pixlet_version': None, + 'installed_apps': installed_count, + 'enabled_apps': enabled_count, + 'plugin_enabled': True, + 'plugin_loaded': False, + 'display_info': {} + }) + + except Exception as e: + logger.exception("[Starlark] get_starlark_status failed") + return jsonify({'status': 'error', 'message': 'Failed to get Starlark status'}), 500 + +@api_v3.route('/starlark/install-pixlet', methods=['POST']) +def install_pixlet(): + """Download and install Pixlet binary.""" + try: + script_path = PROJECT_ROOT / 'scripts' / 'download_pixlet.sh' + if not script_path.exists(): + return jsonify({'status': 'error', 'message': 'Installation script not found'}), 404 + + os.chmod(script_path, 0o755) + + result = subprocess.run( + [str(script_path)], + cwd=str(PROJECT_ROOT), + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode == 0: + logger.info("Pixlet downloaded successfully") + return jsonify({'status': 'success', 'message': 'Pixlet installed successfully!', 'output': result.stdout}) + else: + return jsonify({'status': 'error', 'message': f'Failed to download Pixlet: {result.stderr}'}), 500 + + except subprocess.TimeoutExpired: + return jsonify({'status': 'error', 'message': 'Download timed out'}), 500 + except Exception as e: + logger.exception("[Starlark] install_pixlet failed") + return jsonify({'status': 'error', 'message': 'Failed to install Pixlet'}), 500