Files
LEDMatrix/test/js
ChuckandClaude Opus 5.5 397333ae59 feat(ipc)!: remove the cache-key mailboxes, control socket stage 5 (for the release after 3.8.1) (#773)
* feat(ipc)!: remove the cache-key mailboxes (control socket stage 5)

The control socket is now the only way the web interface sends the display a
command. The display stops reading display_on_demand_request and
plugin_error_clear_request, and the web interface stops writing them.

- Display: no mailbox poll (MailboxWatch, the 1 s / 0.25 s cadence,
  _consume_on_demand_request, the deprecation log) and no persisted
  display_on_demand_processed_id guard; the error publisher reads no clear
  request. CacheManager.file_signature and MailboxWatch are removed.
- A write to either retired key is dropped by CacheManager.save_cache and
  logged once per writer, naming the plugin from the call stack (or the
  request's plugin_id), with the API to move to.
- Web: on-demand start with no display listening starts the service (when
  start_service) and sends the request again once the socket answers (45 s,
  10 s for a running service without a socket yet); every other failure is
  a 503 (400 for invalid_args). Stop answers 503 when no display listens,
  unless stop_service. errors/clear answers 503 with a reason-specific
  message instead of writing a request; clear_pending is always false.
  src.ipc.client.should_fall_back is replaced by display_not_listening.
- Kept: display_current_state, display_on_demand_state,
  plugin_runtime_snapshot and the heartbeat (read whenever the socket cannot
  answer), and display_on_demand_config (the display's resume record).

Tests: mailbox-only tests removed (test_on_demand_mailbox.py, the mailbox
cadence, file_signature and MailboxWatch tests); tests that injected
requests through the mailbox now use the socket queue or a plugin's
in-process request. The run-loop harness sends on-demand requests over its
fake control socket, so four golden traces change: on-demand starts and
stops land at the request instant instead of the next 0.25 s mailbox look
(one frame fewer on the screen they end), and in vegas.json within one
frame instead of 263 ms, which shifts the later 1 s-throttled WiFi-notice
check by under a second.

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

* test: the on-demand routes' other callers ack over the socket, not the mailbox

test_api_v3_bool_coercion, test_api_v3_lazy_plugin_discovery and
test_web_api's stop test checked the mailbox write; they now get an ack
from a mocked control socket. The stop route touches no manager any more,
so it leaves the removed-catch-all sample. A request that was sent is
never 'not listening' (pins the sent check the mutation run let through).

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

* feat(web): a start that waits for the display answers 202 and is delivered in the background

The start route held a request open for up to 45 s while a cold-started
display loaded its plugins; the MQTT bridge (15 s timeout) and browsers
reported a failure for a request that was then delivered.

Now, when no display is listening, the route starts the service if asked
and answers 202 with status "starting" at once. A single worker in the web
process (web_interface/on_demand_dispatch.py) sends the request until the
display acknowledges it or the wait runs out (45 s cold start, 10 s for a
running service without a socket yet). A newer start supersedes the
pending one; a stop cancels it (and succeeds, with cancelled_request_id,
even with no display listening). The outcome is reported by
/display/on-demand/status (source "web": starting, or error with
start-timeout or the socket's reason, until the display publishes
something newer) and by /display/current-status as on_demand_pending.

Callers: the web UI's on-demand modal and "Preview on display" treat
"starting" as taken (an info toast); the MQTT bridge already treats any
non-error 2xx as success (now pinned by a test).

Tests: the dispatcher (ack, retry then ack, start-timeout, other failures,
superseded, an in-flight ack for a superseded start, stop while pending,
a per-start wait, outcome lifetime); the routes (202, status routes while
pending and after a timeout, a later display state replacing the failure,
stop while pending, a new start superseding); a JS suite for app.js.
Mutation check: 20 mutants on the worker, the routes and app.js, 20 killed.

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

* fix(web): a cancelled on-demand start never reaches the display after its replacement

CI caught a race in test_a_new_start_supersedes_the_pending_one: the
dispatcher's worker could read the old start, the route cancel it and send
the new one, and the worker's send of the old one land after it. cancel()
now waits out a send already in flight (the worker holds a send lock while
it reads the pending start and sends it), so whatever the caller sends next
lands after it. Pinned by test_a_cancel_waits_for_a_send_in_flight, which
fails without the wait.

The Linux-only TestRealSocket test for a display that went away now
expects the 202 and the start-timeout that follows, as the route answers
since the background dispatcher.

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

* fix(web): a delivered on-demand start reads as starting until the display acts on it

On ledpi (three cold starts) the display acknowledged the start as its
socket opened, then took ~5 s to act on it while Vegas built its first
strip; the status routes meanwhile showed the display's own idle state, so
a UI polling every 700 ms flashed idle.

The display's on-demand state now names the request it answers
(request_id). A delivered start keeps reading as status "starting" with
delivered: true, in /display/on-demand/status and as on_demand_pending in
/display/current-status, until the display publishes state for that
request id (a display without the field: any state newer than the
delivery), for at most DELIVERED_SHOWN_SECONDS (30 s). The display's
startup state, which can be published after the acknowledgement, names no
request and does not end it.

Tests: stays starting against the startup idle state (no id, an older id);
the matching active state and the matching error take over; an older
display's newer state takes over; the 30 s cap; the display's state names
its request. Mutation check: 11 mutants, 11 killed.

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

* fix(display): on_demand_request_id has a class default for controllers built without __init__

_on_demand_state now publishes it, and test_state_stream_readers builds
controllers with __new__.

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

* test: the case-spelling on-demand test patches _send_on_demand now the mailbox is gone

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

* docs: blank line before 3.8.7 heading

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:31:35 -04:00
..

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_overview_reconciliation_poll.js no The Overview's reconciliation-banner poll from partials/overview.html, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (LEDVisibility, its own key), and stops once the banner is shown
unit/test_display_partial_ids.js no js/pages/display.js started on a fake root that answers only for the ids partials/display.html renders: every id it looks up (with every listener and timer it set fired) exists, and moving the brightness slider updates its label without throwing
unit/test_general_web_login_token.js no createToken from js/pages/general.js, imported with a fake DOM and fetch: a created API token clears the form's data-dirty mark (so a reload does not ask "Leave site?"), a refused one keeps it
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_schedule_page.js yes The Schedule tab (js/pages/schedule.js) with the real schedule-picker widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points
dom/test_visibility_service.js yes (no server) js/core/visibility.js with the real LEDVisibility from app-shell.js and the real registry: start/stop with the active tab and the browser tab's visibility, no interval while hidden or after a swap-out, registrations independent, the no-LEDVisibility fallback
dom/test_display_page.js yes The Display tab (js/pages/display.js) with the real plugin-order-list widget and LEDVisibility: one page, one sync interval and one action per control after repeated swaps, the sync poll only while on screen and never after a swap-out, sync states as text, the debounced scroll-speed hint, the refresh-cap hint and its "Use N Hz" button, updateSyncUI's entry point
dom/test_general_page.js yes The General tab (js/pages/general.js) with the real timezone-selector widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, webLogin's 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.