Files
LEDMatrix/test/js/README.md
T
ChuckandClaude Opus 5.5 8a0cce1aaf fix(web): mask the Config Editor's secrets; keep disabled plugins' rotation slot and Vegas exclusion; restore only missing plugins (#743)
* fix(web): mask the Config Editor's secrets like GET /config/secrets

The Config Editor tab (/partials/raw-json) filled its config_secrets.json
editor with the file as it is on disk. GET /api/v3/config/secrets masks every
value because the interface is reachable without a login by default, but
this page handed the same credentials (GitHub token, Home Assistant token,
plugin API keys) to anyone who loaded it. The masked-save path in
save_raw_secrets_config was written for a masked editor and never got one.

_load_raw_json_partial now masks the section with mask_all_secret_values
after strip_auth_section, exactly as the GET does. Saving it back is safe:
save_raw_secrets_config drops the masks (strip_masked_values) and merges the
rest onto the stored file (deep_merge), so an untouched secret stays as it
is and a replaced mask is the only value that changes.

The config.json editor is left as it is. Its save (save_raw_main_config)
writes the posted object verbatim, with no mask stripping or merge, so a
masked main editor would write the bullets over any credential it holds.
Masking it needs a merge-on-save of its own first.

Tests: TestConfigEditorRoundTrip renders the partial over a real
ConfigManager, checks no real value is in the editor, and posts the editor
back unchanged (the file is identical) and with one mask replaced (only that
value changes).

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

* fix(web): keep disabled plugins in the saved rotation order and Vegas exclusions

PluginOrderList draws one row per enabled plugin and, once drawn, rewrites
its hidden inputs (plugin_rotation_order, vegas_plugin_order,
vegas_excluded_plugins) from those rows. A disabled plugin has no row, so
merely opening the Display or Rotation & Durations tab took it out of the
inputs, and the next save of that form stored the lists without it. Exclude
Clock from Vegas, disable it, change the brightness, re-enable it: Clock was
scrolling in Vegas again and had moved to the end of the rotation.

syncInputs now keeps the saved ids that have no row. In the order, each one
keeps its saved slot and the rows fill the other slots in their current
order, with rows not in the saved order last, as before. In the exclusions
they follow the unchecked rows. Only string ids are carried over, once each:
/config/main refuses a list holding anything else, which would block every
later save of the tab.

Tests: test/js/unit/test_plugin_order_list.js runs the shipped widget in a vm
with a fake DOM (draw, reorder, include/exclude, the rotation list, junk ids)
and is in run_all.js and the README. The durations DOM suite now reads only
its own rows' ids from the input, since a rig's saved order can hold others.

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

* fix(web): a restore reinstalls only the plugins that are missing

POST /backup/restore with reinstall_plugins (the "Reinstall missing plugins"
box) passed every plugin in the backup's plugins.json to
install_plugin(). That replaces an installed copy with a fresh download, so
a restore onto the same device re-downloaded every plugin inside the
request. A plugin installed from its own URL is not in the registry, so its
install returned False, plugins_failed set success to False, and the restore
answered 500 "Restore incomplete ... plugins not reinstalled: <id>" (shown
as "Restore failed") with the plugin still installed and the config
restored.

Each plugin is now looked up first with the store's _existing_install, the
same lookup install_plugin makes to decide a copy exists: the id, or an id
the registry proves is the same plugin (aliases, the plugin_path name), and
never a bare ledmatrix-<id> folder (#686). One that is installed is recorded
in result.skipped as "plugin:<id> (installed)", which the page lists under
Skipped; a missing one is installed as before. The list_installed_plugins
docstring said every listed plugin is reinstalled and now says otherwise.

Tests: TestInstalledPluginsAreNotReinstalled, with a mocked store (installed
skipped, missing installed; an installed plugin the store can't install is
not a failure) and with a real PluginStoreManager (a registry alias and a
third-party install are skipped, a missing plugin installed).

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

* fix(web): /config/main answers malformed JSON with a 400

save_main_config read a JSON body with request.get_json(), which raises
Werkzeug's BadRequest for a body that does not parse (or an empty one sent as
application/json). That happened inside the handler's try, so the
catch-all answered 500 CONFIG_SAVE_FAILED with "Check file permissions on
config directory" among its suggested fixes and logged a traceback at
ERROR, for what was the caller's mistake.

It now reads with get_json(silent=True), as save_raw_main_config does, and
answers a sent-but-unparseable body with the same 400
{"status": "error", "message": "Invalid JSON in request body"}. An empty
JSON body falls through to the existing 400 "No data provided". The change
is limited to the lines that read the body.

Tests: TestMalformedBody in test_api_v3_partial_main_save.py (the 400 and its
shape, identical to /config/raw/main's, and nothing saved; the empty body).

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

* fix(web): a restore that brings back fonts clears the font catalog cache

GET /api/v3/fonts/catalog caches its answer as fonts_catalog for five
minutes. Font upload and delete clear that entry (fonts.py), but
POST /backup/restore copies user fonts into assets/fonts without touching
it, so restored fonts were missing from the Fonts tab and every font picker
until the cache expired.

backup_restore now clears fonts_catalog when the result lists restored fonts
(restore_backup records them as "fonts (<count>)"). A restore that restored
no fonts leaves the cache alone.

Tests: TestFontsCatalogCache in test_api_v3_backup_restore.py.

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

* fix(web): drop uninstalled plugins from the carried-over order and exclusions

2b34f254 made the plugin order list keep every saved id that has no row,
so a disabled plugin keeps its rotation slot and Vegas exclusion. That
also kept the ids of plugins that have since been uninstalled: they stayed
in plugin_rotation_order and vegas_excluded_plugins for good, where before
the next save of the tab dropped them.

The widget already fetches /api/v3/plugins/installed, every installed plugin
with its enabled flag, and draws only the enabled ones. It now keeps that
response's full id set and carries over only saved ids that are installed
but have no row (disabled). An id outside the set is dropped, as before.
With no list, nothing is dropped: a failed request draws no rows and leaves
the inputs as saved, and the carry-over keeps everything if the set was
never filled.

Tests: test/js/unit/test_plugin_order_list.js adds a disabled plugin kept
while an uninstalled one is dropped (order and exclusions; fails on
2b34f254), and a failed plugin list leaving both inputs as saved. The
CHANGELOG bullet and the README row say so.

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

* test(js): register the order-list suite apart from other branches' suites

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:30:28 -04:00

10 KiB

Web-interface JS tests

Covers web_interface/static/v3/js/plugins/list_filter.js (the shared search/filter/sort controller) and the plugin-manager grids that use it: Installed Plugins, the Plugin Store, and Starlark Apps.

There is no JS toolchain in this repo, so these are plain node scripts with no test framework. Each prints ok/FAIL lines and exits non-zero on failure.

Running

cd test/js
npm install                 # jsdom, for the DOM suites only
node run_all.js

The unit suites need nothing but node; test/test_js_unit_suites.py runs every unit/*.js under pytest, so CI covers them. The DOM suites additionally need a running web interface, because they test against the real server-rendered HTML and the real API rather than fixtures:

# in another shell, from the repo root
EMULATOR=true python3 web_interface/app.py         # http://localhost:5000

# or point the suites at a device
BASE=http://<pi-ip>:5000 node run_all.js

run_all.js skips the DOM suites (rather than failing) when jsdom is missing or nothing is listening, so it stays useful in a bare checkout. REQUIRE_DOM=1 makes that a failure instead.

CI runs everything: the Web UI JS tests job in .github/workflows/test.yml installs jsdom, starts the web interface in emulator mode on port 5000 and runs run_all.js with REQUIRE_DOM=1. The DOM suites don't assume a particular device: the store suite checks pagination whichever side of 48 plugins the live registry is, and the Tools suite supplies two sample Starlark apps when the server has none.

The suites

Suite Needs a server Covers
unit/test_list_filter.js no ListFilter search/filter/sort/count/sticky, and the installed-plugins config extracted verbatim from plugins_manager.js so the test can't drift from it
unit/test_update_all.js no PluginInstallManager.updateAll from plugins/install_manager.js: Check & Update All sends only plugin ids (never starlark: app entries), re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin, never re-sends one that got any HTTP answer (the real api_client.js classifies a proxy 502 or a JSON error without error_code as API_ERROR), and counts a no-op update as already up to date in the summary. Also run by test/web_interface/test_update_all_plugins.py so CI covers it
unit/test_store_install.js no The store's Install button, with the whole of plugins_manager.js run by plugins_manager_sandbox.js (a vm context, fake DOM and API): a fresh install reloads the list, then enables the id the plugin was installed as -- the answer's plugin_id, else the installed entry the store entry matches (Weather installs as ledmatrix-weather); a Reinstall leaves the enabled state alone
unit/test_install_polling.js no How long Install waits for a queued install (sandbox): at least the server's 300 s dependency-install timeout; when it stops waiting it reloads the installed list and warns, rather than reporting a failure or enabling anything
unit/test_store_categories.js no The store's category filter (sandbox): the template ships only All Categories, the rest come from the store's plugins (one per category whatever its case), choosing one filters to it, and a swapped-in select is refilled from the cache keeping the choice
unit/test_github_url_install.js no Install Single Plugin (sandbox, the button as plugins.html ships it): no inline onclick, so a click or Enter sends exactly one install-from-url request and raises no error
unit/test_render_cards.js no renderInstalledCards markup, both empty states, and HTML-escaping of hostile plugin metadata
unit/test_plugin_order_list.js no widgets/plugin-order-list.js (the Vegas and rotation order lists): a disabled plugin, which gets no row, keeps its slot in the saved order and its Vegas exclusion when the list rewrites its hidden inputs, around reordering and include/exclude; an uninstalled plugin's id is dropped, a failed plugin list leaves the inputs as saved, and only string ids are carried over, once each
unit/test_style_editor_element_keys.js no elementKeys()/styleRows()/positionRows() from widgets/style-editor.js: every customization.layout entry gets exactly one row -- paired with its style element through core's x-layout-key (so score belongs to score_text, not a second row), or a position row of its own, leaves included -- since the widget claims the whole layout block from the generic fallback renderer
unit/test_style_editor_layout_leaf_columns.js no columnsFor() from widgets/style-editor.js: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a show_logo toggle) gets a self-keyed column instead of a blank, uneditable row
unit/test_style_editor_layout_leaf_collision.js no columnsFor() from widgets/style-editor.js: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field
unit/test_inline_handler_escaping.js no The store, saved-repository and custom-registry inline onclick handlers and the live window.updateImageList from plugins_manager.js: a registry id, URL or uploaded file name carrying ', " or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links
unit/test_store_registry_fields.js no The store card's registry fields from plugins_manager.js: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and isStorePluginInstalled answering to aliases
unit/test_page_registry.js no The page lifecycle in js/core/registry.js (a minimal DOM shim): one init per data-page root, destroy and an aborted ctx.signal when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap
unit/test_core_modules.js no js/core/api.js (JSON envelope, HTTP/status: error/network errors, abort passthrough, the #683 login redirect, same-server paths only) and js/core/facade.js (window.LEDMatrix, deprecated aliases)
unit/test_plugin_action_delegation.js no The document-level card-action delegation and handlePluginAction from plugins_manager.js, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to DELETE /starlark/apps/<id>, and an uninstall is confirmed once
dom/test_installed_dom.js yes The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a getComputedStyle check that .filter-pill[data-active] really matches the emitted markup
dom/test_store_dom.js yes Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry
dom/test_no_double_fetch.js yes Loads the whole plugins_manager.js and counts requests: typing in the store search must filter the cached list, not refetch /api/v3/plugins/store/list
dom/test_cache_page.js yes The Cache tab as a page module (js/pages/cache.js) on the real partial: no inline script, one request per swap and per Refresh after repeated swaps, a cancelled request draws nothing, hostile keys stay text, delete/empty/error/login states
dom/test_durations_page.js yes The Rotation tab (js/pages/durations.js) with the real plugin-order-list.js widget: one plugin-list request per swap, one move per click after repeated swaps, a swap cancels the request in flight, a late widget is waited for
dom/test_operation_history_page.js yes The Operation History tab (js/pages/operation-history.js): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text
dom/test_raw_json_page.js yes The Config Editor tab (js/pages/raw-json.js): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points
dom/test_backup_restore_page.js yes The Backup & Restore tab (js/pages/backup-restore.js): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points
dom/test_tools_sections.js yes The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from

Point the DOM suites at a rig with a full plugin set when it matters — a dev box with two plugins installed will pass while exercising very little.

Notes for whoever changes this next

  • The suites read the shipped files off disk and, for the DOM ones, the partial from the running server. They do not keep their own copy of the markup, so renaming an element id will fail them loudly rather than silently pass.
  • unit/test_list_filter.js evals a slice of plugins_manager.js located by the text function installedSortName(plugin). If that function is renamed, fix the slice markers rather than pasting a copy of the config into the test.
  • A few assertions exist specifically to stop earlier bugs coming back: trailing spaces surviving the search debounce; a multi-word query that spans two adjacent search fields (field order in the haystack is load-bearing); window.installedPlugins staying at full length while the grid is filtered.
  • Watch for assertions that can pass vacuously. Several here deliberately guard against it — e.g. counting only non-skeleton cards, and asserting a search phrase matches something before comparing two results.

The old-vs-new differential suites used to verify that the store and Starlark migrations were behaviour-preserving are not included: they compared against the pre-refactor implementation, which now only exists in git history. See PR #540 if that comparison ever needs redoing.