Compare commits

...
6 Commits
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 2538690435 fix(starlark): restore the remaining eleven routes, so the app store works
The Pixlet button was the reported symptom; the app store is dead the
same way. #330 dropped all thirteen Starlark routes, and browse,
categories and install are what the store page is built on -- each
answering the generic 404, which from the UI is indistinguishable from
an empty store.

Restores the other eleven from 1c4d5c52^: apps list and detail, delete,
per-app config get/put, toggle, render, manual .star upload, and the
Tronbyte repository browse/categories/install. Their dependency closure
came with them (8 helpers, resolved by walking the handlers' references
rather than by eye), plus the Tuple and Type imports the old file had.

Two changes rather than a straight revert. The restored handlers used
`request.get_json()` where the file has since standardised on
`silent=True`: without it Werkzeug raises on a bodyless POST before the
handler's own `if not data` guard runs, so the caller gets a framework
error instead of the declared 400. test_api_v3_optional_body.py already
checks for exactly that and caught all three.

test_every_endpoint_the_frontend_calls_is_registered reads the URLs out
of plugins_manager.js and matches each through the URL map, so a rewrite
of this file cannot quietly drop the set again -- one assertion over the
frontend's own list is what would have caught #330.

26 tests, 17 of which fail against main. Full core suite: 3973 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
2026-09-07 14:14:23 -04:00
ChuckBuildsandClaude Opus 5 76c5e367ef 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
2026-09-07 13:47:14 -04:00
ChuckClaude Opus 5coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
696acdbc7b feat(render_plugin): add --display-mode so multi-mode plugins can be rendered (#522)
* feat(render_plugin): add --display-mode so multi-mode plugins can be rendered

render_plugin.py always called plugin.display(force_clear=True) with no mode.
A plugin that declares one display mode is fine, but the sports scoreboards
declare three or more and keep their per-mode state on sub-managers; their
no-argument path selects nothing and returns False, so the render came out
blank with nothing to say why. Measured on nrl-scoreboard with identical
seeded state:

  live.display() directly                 True,  1892 lit pixels
  plugin.display(display_mode="nrl_live") True,  1892 lit pixels
  plugin.display()                        False,    0 lit pixels

--display-mode passes the requested mode through. It is only passed when
asked for, so the many plugins whose display() takes no display_mode keep
working untouched, and a plugin that declares modes but does not accept the
argument degrades to its default screen with a warning rather than a
TypeError.

This is what lets the plugin READMEs show a scoreboard at all, and it also
unblocks screens like birdnet_stats and the weather plugin's hourly, daily and
almanac modes, which could previously only be described in prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Only fall back when plugin display rejects display_mode

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-09-06 17:28:22 -04:00
ChuckandClaude Opus 5 91d15a8943 fix(display): draw text 1-bit, so glyphs stay crisp on the LED grid (#521)
An LED panel has no partial brightness. PIL defaults ImageDraw's fontmode to
"L", which anti-aliases TrueType glyphs into a grey fringe the panel can only
round off -- a 4px glyph arrives smeared into 3px.

DisplayManager creates its shared `draw` in six places and set fontmode at
none of them, while _load_fonts loads extra_small_font as 4x6-font.ttf at
size 6. Measured at draw time, that face at that size puts 74% of its lit
pixels at partial coverage. Every plugin drawing small text through the
shared draw inherited the blur; geochron was the case that surfaced it.

The harness's VisualDisplayManager had the same gap, which mattered more than
it looks: goldens were recording anti-aliased text that production would not
produce, so the harness could not have caught this. Fixing only production
left geochron still blurry under the harness -- that is how the second site
was found.

Both are set to "1" so the harness renders what the panel renders.


Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:02:04 -04:00
Chuck 6bea1a7c21 fix(sports): say when the schema cannot be read, instead of failing silently (#520)
_schema_font_size swallowed every exception and cached an empty dict. That is
not cosmetic. With no schema, a configured font size can no longer be compared
against the schema default, so every size is treated as a deliberate user
choice and skips the snap to the font's pixel grid -- which renders
4x6-font.ttf at 6 instead of 7: a 3px-wide glyph instead of 4px.

That shipped. On a 256x64 panel it made the odds, the team records and the date
row hard to read, and it was found by a user counting pixels on a photo of the
panel rather than by anything here. The cause (_plugin_dir returning None under
the real plugin loader) is fixed in #519; this makes the same class of failure
audible next time:

    Orphan: could not read config_schema.json (FileNotFoundError: ...); every
    font size will be treated as user-chosen and will skip its pixel grid
    snap. Font sizes may render a pixel narrow.

The message names the consequence, not just the error, because the error alone
does not suggest "your fonts are a pixel narrow".

Logged rather than raised: an unreadable schema must not stop a plugin
rendering. The cache is built once per class (per schema path in sports_card),
so this cannot repeat per frame.

Scope deliberately small. An audit of the three shared modules found 23 handlers
that swallow and return a default, but all 23 catch specific types -- TypeError,
ValueError, ImportError -- turning bad config values into defaults, which is
what they are for. Of 77 broad handlers across the font and odds paths, 74
already log. Only these two were both broad and silent.
2026-09-04 16:01:50 -04:00
Chuck 0730d95200 fix(sports): let the plugin declare its own directory, don't deduce it (#519)
_plugin_dir() returned None on every device. The consequence was silent and
reached the panel:

    _plugin_dir()       -> None
    _schema_font_size() -> None for every element
    -> a configured size equal to the schema default stops looking like a
       default and is treated as a deliberate user choice
    -> the snap to the font's pixel grid is skipped
    -> 4x6-font.ttf renders at 6 instead of 7: 3px-wide glyphs, not 4px

On a 256x64 panel that made the odds, the team records and the date row hard to
read. Both `odds` and `detail` were affected -- anything resolving a
grid-snapped schema default was a pixel narrow.

Why it was invisible here. PluginLoader._namespace_plugin_modules renames every
bare module a plugin brought in (sports, game_renderer, ...) to
"_plg_<plugin_id>_<module>" and REMOVES the bare sys.modules entry, so two
plugins owning a module of the same name cannot collide. A class defined in
sports.py still reports __module__ == "sports", but sys.modules["sports"] is
gone, so walking the MRO for a module with a __file__ finds nothing.

Every test here imported plugins directly, which leaves the bare entry in
place, so the walk succeeded. The safety harness loads plugins its own way and
never reproduced it either. It was found by a user counting pixels on the
panel.

The directory is now declared by the plugin (_PLUGIN_DIR) and only deduced as
a fallback, for hosts that declare nothing -- the plugins' own probe harnesses
build classes with type().

Verified on hardware, which is the only place the original failure appeared:
before, the live service logged plugin_dir=None and 4x6-font.ttf@6 for all six
football managers; after, plugin_dir resolves and both odds and detail are @7.

Five regression tests, including the production shape: a class whose __module__
is absent from sys.modules still resolves via its declared directory, and the
precondition that the MRO walk alone returns None is pinned so the test keeps
meaning something if the fallback changes.
2026-09-03 17:01:30 -04:00
8 changed files with 1374 additions and 12 deletions
+23 -1
View File
@@ -52,6 +52,10 @@ def main() -> int:
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
parser.add_argument('--skip-update', action='store_true',
help='Skip calling update() (render display only)')
parser.add_argument('--display-mode', default=None,
help='Display mode to render, for plugins that declare '
'more than one in their manifest (e.g. nrl_live). '
'Omitted, the plugin picks its own default.')
args = parser.parse_args()
@@ -141,8 +145,26 @@ def main() -> int:
except Exception as e:
logger.warning("update() raised: %s — continuing to display()", e)
# A plugin that declares several display modes usually renders nothing
# useful without being told which one to draw: the scoreboards keep their
# state on per-mode sub-managers and their no-argument path returns False.
# Only pass the argument when asked for, so the many plugins whose display()
# takes no display_mode keep working untouched.
try:
plugin_instance.display(force_clear=True)
if args.display_mode:
try:
plugin_instance.display(display_mode=args.display_mode,
force_clear=True)
except TypeError as error:
if ("unexpected keyword argument" not in str(error)
or "display_mode" not in str(error)):
raise
logger.warning(
"%s.display() does not accept display_mode; rendering its "
"default screen instead", args.plugin)
plugin_instance.display(force_clear=True)
else:
plugin_instance.display(force_clear=True)
logger.debug("display() completed")
except Exception as e:
logger.error("Error in display(): %s", e)
+11 -1
View File
@@ -17,10 +17,13 @@ deliberate difference is `crisp_size`, which takes the seven-plugin guard
the extra guard only stops a None size raising TypeError.
"""
import logging
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
__all__ = [
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
@@ -395,7 +398,14 @@ def schema_font_size(schema_path: str, element_key) -> Optional[int]:
size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None:
cache[key] = int(size)
except Exception:
except Exception as exc:
# See sports_shared._schema_font_size: an unreadable schema
# silently disables the pixel-grid snap for every element.
# Built once per schema path, so this cannot repeat per frame.
logger.warning(
"could not read %s (%s: %s); font sizes will skip their "
"pixel grid snap and may render a pixel narrow",
schema_path, type(exc).__name__, exc)
cache = {}
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
return cache.get(element_key)
+59 -8
View File
@@ -211,17 +211,52 @@ class SportsCoreSharedMixin:
return desired
return max(grid, int(round(float(desired) / grid)) * grid)
#: Absolute path of this plugin's directory, declared by the plugin
#: itself. The mixin cannot work it out -- see _plugin_dir.
_PLUGIN_DIR: ClassVar[Optional[str]] = None
def _plugin_dir(self) -> Optional[str]:
"""Directory of the plugin that defines this class.
"""Directory of the plugin that owns this instance.
In sports.py these methods could just use ``__file__``. Here that is
src/common/, so the plugin's own directory has to be recovered from the
instance. ``type(self).__module__`` alone is not enough: SportsCore is
an ABC, so a subclass built with ``type(name, bases, ns)`` -- which the
plugins' own tests do -- reports its module as "abc". Walking the MRO
steps past those synthetic classes to the first one whose module sits
next to a config_schema.json, which is the real plugin.
src/common/, so the directory has to come from the plugin.
It is TOLD, not deduced. The first version walked the MRO for a class
whose module sits beside a config_schema.json. That works when a test
imports the plugin itself, and returns None under the real loader, for
a specific reason worth recording:
PluginLoader._namespace_plugin_modules renames every bare module a
plugin brought in (sports, game_renderer, ...) to
"_plg_<plugin_id>_<module>" and REMOVES the bare entry, so two
plugins owning a module of the same name cannot collide.
The class still reports ``__module__ == "sports"``, but
``sys.modules["sports"]`` no longer exists, so the walk finds no
__file__ and falls off the end. The failure was silent and expensive:
_plugin_dir() -> None
_schema_font_size() -> None for every element
-> a configured size equal to the schema default stops looking
like a default and is treated as a deliberate user choice
-> the snap to the font's pixel grid is skipped
-> 4x6-font.ttf renders at 6 instead of 7: 3px-wide glyphs
instead of 4px
On a 256x64 panel that made the odds, the team records and the date row
hard to read. It was found by a user counting pixels on the panel. No
gate here caught it: the tests imported plugins directly and the
safety harness loads them its own way, so neither reproduced the
loader's renaming.
The MRO walk stays as a fallback for hosts that declare no
_PLUGIN_DIR -- the plugins' own probe harnesses build classes with
``type()`` -- but it is no longer the primary answer.
"""
declared = getattr(self, "_PLUGIN_DIR", None)
if declared and os.path.isfile(os.path.join(declared, "config_schema.json")):
return declared
for cls in type(self).__mro__:
module = sys.modules.get(getattr(cls, "__module__", ""), None)
path = getattr(module, "__file__", None)
@@ -253,7 +288,23 @@ class SportsCoreSharedMixin:
size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None:
cache[key] = int(size)
except Exception:
except Exception as exc:
# Say so. An unreadable schema is not cosmetic: every element's
# configured size then stops matching "the schema default", is
# treated as a deliberate user choice, and skips the snap to the
# font's pixel grid -- which renders 4x6-font.ttf at 6 instead
# of 7, a 3px-wide glyph instead of 4px. That shipped once,
# silently, and was found by a user counting pixels on a photo
# of the panel.
#
# Logged, not raised: a missing schema must not stop a plugin
# rendering. The cache is built once per class, so this cannot
# repeat per frame.
logger.warning(
"%s: could not read config_schema.json (%s: %s); every font "
"size will be treated as user-chosen and will skip its pixel "
"grid snap. Font sizes may render a pixel narrow.",
type(self).__name__, type(exc).__name__, exc)
cache = {}
self.__class__._SCHEMA_FONT_SIZES = cache
return cache.get(element_key)
+6
View File
@@ -364,6 +364,7 @@ class DisplayManager:
# Create image with the (logical) display dimensions
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
# Initialize font with Press Start 2P
@@ -403,6 +404,7 @@ class DisplayManager:
self.image = Image.new('RGB', (fallback_width, fallback_height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
# Simple fallback visualization so web UI shows a realistic canvas
try:
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
@@ -705,6 +707,7 @@ class DisplayManager:
# self.image, so swapping the buffer below is enough on its own.
self.image = Image.new('RGB', (target_w, target_h))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
yield
finally:
self.matrix = real_matrix
@@ -814,6 +817,7 @@ class DisplayManager:
self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.debug("Cleared display in fallback mode")
return
@@ -825,6 +829,7 @@ class DisplayManager:
# Create a new black image
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
if not self._capture_mode_active:
# Clear both canvases and the underlying matrix to ensure no artifacts.
@@ -1258,6 +1263,7 @@ class DisplayManager:
try:
self.image = Image.new('RGB', (self.width, self.height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
except (OSError, RuntimeError, ValueError, MemoryError):
logger.debug("Canvas reset during cleanup failed", exc_info=True)
# Reset the singleton state when cleaning up
@@ -70,6 +70,7 @@ class VisualTestDisplayManager:
# Canvas
self.image = Image.new('RGB', (width, height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
# Matrix proxy (plugins access display_manager.matrix.width/height)
self.matrix = _MatrixProxy(width, height)
@@ -184,6 +185,7 @@ class VisualTestDisplayManager:
self.clear_called = True
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
def update_display(self):
"""No-op for hardware; marks that display was updated."""
@@ -211,6 +213,7 @@ class VisualTestDisplayManager:
self.matrix = _MatrixProxy(target_w, target_h)
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
yield
finally:
self._width, self._height = prev_w, prev_h
@@ -569,6 +572,7 @@ class VisualTestDisplayManager:
self.draw_calls = []
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
self._scrolling_state = {
'is_scrolling': False,
'last_scroll_activity': 0,
@@ -582,3 +586,4 @@ class VisualTestDisplayManager:
"""Clean up resources."""
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
+55
View File
@@ -319,3 +319,58 @@ class TestLiveMixin:
assert not hasattr(h, "_empty_live_streak")
h._note_live_fetch(False)
assert h._empty_live_streak == 1
class TestPluginDirIsToldNotDeduced:
"""The regression that shipped: _plugin_dir returned None in production.
The first version walked the MRO for a class whose module sits beside a
config_schema.json. That passes when a test imports the plugin directly --
which is how it was verified -- and returns None under the real plugin
loader, which imports modules by a path that leaves no such entry on the
MRO.
Silent, and expensive: no schema means _schema_font_size returns None for
every element, so a configured size equal to the schema default stops
looking like a default, is treated as a deliberate choice, and skips the
grid snap. 4x6-font.ttf then renders at 6 rather than 7 -- 3px-wide glyphs
instead of 4px. On a 256x64 panel that made the odds, the records and the
date row illegible. A user counted the pixels; no gate here caught it.
"""
def test_a_declared_directory_is_used(self, tmp_path):
d = _write_plugin(tmp_path, "declared")
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
assert host._plugin_dir() == str(d)
def test_it_works_when_no_module_on_the_mro_helps(self, tmp_path, monkeypatch):
"""The production case: nothing on the MRO sits beside a schema."""
d = _write_plugin(tmp_path, "loaderstyle")
# A class whose module is not importable by name, as the loader produces.
cls = type("Loaded", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})
cls.__module__ = "a.module.name.that.is.not.in.sys.modules"
assert cls.__new__(cls)._plugin_dir() == str(d), (
"the declared directory must win when the MRO walk cannot help")
def test_without_it_the_mro_walk_would_have_failed(self):
# Pin the precondition, so this test still means something if the
# fallback is ever changed.
cls = type("Orphan", (SportsCoreSharedMixin,), {})
cls.__module__ = "not.a.real.module"
assert cls.__new__(cls)._plugin_dir() is None
def test_a_declared_directory_without_a_schema_is_not_trusted(self, tmp_path):
# A stale or wrong path must not shadow the fallback.
empty = tmp_path / "noschema"
empty.mkdir()
d = _write_plugin(tmp_path, "realone")
monkey = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(empty)})
assert monkey.__new__(monkey)._plugin_dir() != str(empty)
def test_the_font_size_consequence(self, tmp_path):
"""End to end: a declared directory restores the schema lookup."""
d = _write_plugin(tmp_path, "sizeconseq")
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
assert host._schema_font_size("score") == 16, (
"without the schema this is None, which is what made a configured "
"size look user-chosen and skipped the grid snap")
@@ -0,0 +1,201 @@
"""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.
All thirteen, not just the two the Pixlet button needs: #330 dropped the
lot, and the app store page is built on repository/browse,
repository/categories and repository/install, which 404 the same way.
"""
@pytest.mark.parametrize("rule,method", [
("/api/v3/starlark/install-pixlet", "POST"),
("/api/v3/starlark/status", "GET"),
("/api/v3/starlark/apps", "GET"),
("/api/v3/starlark/upload", "POST"),
("/api/v3/starlark/repository/browse", "GET"),
("/api/v3/starlark/repository/categories", "GET"),
("/api/v3/starlark/repository/install", "POST"),
("/api/v3/starlark/apps/<app_id>", "GET"),
("/api/v3/starlark/apps/<app_id>", "DELETE"),
("/api/v3/starlark/apps/<app_id>/config", "GET"),
("/api/v3/starlark/apps/<app_id>/config", "PUT"),
("/api/v3/starlark/apps/<app_id>/toggle", "POST"),
("/api/v3/starlark/apps/<app_id>/render", "POST"),
])
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)
class TestTheAppStoreFlow:
"""Browsing and installing from the Tronbyte repository.
These are the calls the app store page makes. Each returned the generic
"Resource not found" before this change, which is indistinguishable from
an empty store.
"""
def test_browse_does_not_404(self, client):
resp = client.get('/api/v3/starlark/repository/browse')
assert resp.status_code != 404, "the store cannot list anything"
assert resp.get_json().get('message') != 'Resource not found'
def test_categories_does_not_404(self, client):
resp = client.get('/api/v3/starlark/repository/categories')
assert resp.status_code != 404
assert resp.get_json().get('message') != 'Resource not found'
def test_installed_apps_list_does_not_404(self, client):
resp = client.get('/api/v3/starlark/apps')
assert resp.status_code != 404
assert resp.get_json().get('message') != 'Resource not found'
def test_repository_install_rejects_a_missing_body_rather_than_404ing(self, client):
# A 400/422 here is the route working: it received the call and said
# what was wrong. A 404 means it was never reached at all.
resp = client.post('/api/v3/starlark/repository/install',
json={}, content_type='application/json')
assert resp.status_code != 404, "the install route is still missing"
assert resp.get_json().get('message') != 'Resource not found'
def test_upload_rejects_an_empty_post_rather_than_404ing(self, client):
resp = client.post('/api/v3/starlark/upload')
assert resp.status_code != 404
assert resp.get_json().get('message') != 'Resource not found'
class TestNoStarlarkRouteIsMissing:
"""A single check that the whole set is present.
#330 removed all thirteen at once by rewriting this file. One assertion
over the frontend's own list is what would have caught that.
"""
def test_every_endpoint_the_frontend_calls_is_registered(self):
import re
from pathlib import Path
from werkzeug.exceptions import MethodNotAllowed, NotFound
from web_interface.app import app
root = Path(__file__).resolve().parent.parent.parent
js = (root / 'web_interface' / 'static' / 'v3' / 'plugins_manager.js').read_text()
# The frontend builds some of these with template literals, e.g.
# `/api/v3/starlark/apps/${appId}/toggle`. Substitute a placeholder so
# the URL is concrete, then let Werkzeug match it the way a request
# would -- string comparison cannot see <app_id> rules.
raw = set(re.findall(r"[\'\"`](/api/v3/starlark/[^\'\"`\s]*)", js))
urls = set()
for u in raw:
u = re.sub(r"\$\{[^}]*\}", "probe", u)
urls.add(u.rstrip('/') or u)
assert urls, "found no starlark calls in the frontend -- did the file move?"
adapter = app.url_map.bind('localhost')
missing = []
for u in sorted(urls):
try:
adapter.match(u, method='GET')
except MethodNotAllowed:
pass # route exists, just not for GET -- fine
except NotFound:
missing.append(u)
assert not missing, f"the frontend calls these and they are not registered: {missing}"
File diff suppressed because it is too large Load Diff