Compare commits

...
2 Commits
Author SHA1 Message Date
ChuckandClaude Opus 5 4423ec33d5 feat(plugins): search, filter and sort for Installed Plugins, on a shared ListFilter helper (#540)
* feat(plugins): add search, filter and sort to Installed Plugins, on a shared helper

The Installed Plugins grid had no way to narrow it down: no search, no way to
see only what's enabled, disabled, or out of date. On a rig with a couple dozen
plugins that means scrolling the whole grid to find one.

The two sections below it already solved this, twice, independently — the
Plugin Store and Starlark Apps carried a copy-paste fork of the same ~600 lines
(filter state, apply-filters-and-sort, page renderer, pagination strip,
active-filter badge, listener wiring). Rather than add a third copy, this
extracts the shared machinery and builds the new toolbar on it.

New: web_interface/static/v3/js/plugins/list_filter.js — ListFilter.create()
owns debounced search, filter axes, sort, the active-filter count, Clear, and
optional pagination/persistence. Callers keep their own card markup via a
`render` callback. Three control types cover every axis the page uses: pills
(new), select (store category, starlark author) and cycle (the tri-state
All -> Installed -> Not Installed button).

Installed Plugins gets a compact toolbar: search box, one-click All / Enabled /
Disabled / Updates pills, and a sort dropdown (A-Z, Z-A, updates first,
recently updated, category). Filters reset on load, so you never come back to a
mysteriously short list. No new CSS — this is the first consumer of the
.filter-pill rules already sitting unused in app.css.

renderInstalledPlugins() is split so it still publishes canonical state while
renderInstalledCards() draws only the visible subset; the filtered list is
never assigned to window.installedPlugins, which the toggle handler,
isStorePluginInstalled(), runUpdateAllPlugins() and the Alpine config tabs all
read as their source of truth. Toggling a plugin while filtered pins its card
so it doesn't vanish from under the cursor.

The Store and Starlark migrations are behaviour-preserving: same element ids,
same localStorage keys (storeSort/storePerPage, starlarkSort/starlarkPerPage),
same tri-state button markup, same pagination. Verified by differential tests
that run the old and new implementations side by side against identical
fixtures and compare every observable after each interaction. The only visible
change is the pagination attribute (data-store-page/data-starlark-page ->
data-list-page), which nothing outside its own click handler referenced.

Net -156 lines in plugins_manager.js while adding a feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): keep raw search text, and stop the store search refetching

Two review findings from CodeRabbit on #540.

Do not write the trimmed search value back into the input. setSearch() trimmed
before storing, and syncControls() then copied that trimmed value back over what
the user had typed. Pausing longer than the debounce after typing a space
deleted the space (and reset the caret), making multi-word terms effectively
untypable. The raw text is now kept alongside the trimmed one: filtering and
activeCount() still use the trimmed value, while the input keeps exactly what
was typed.

Remove the legacy #plugin-search / #plugin-category listeners in
initializePlugins(). They bound searchPluginStore as the event handler, so the
DOM event arrived as its `fetchCommitInfo` argument — always truthy, which
skipped the cached-filter fast path and refetched /api/v3/plugins/store/list
with commit info on every keystroke burst and category change. The store's
ListFilter controller already filters the cached list, which is what those two
controls should do. This double-binding predates this PR (the old code guarded
with _listenerSetup and _storeFilterInit, two different flags, so both sets
stayed live); it is fixed here because the refactor owns that wiring now.

Both fixes are covered by tests that fail without them: the trailing-space
regressions in the installed-plugins DOM suite, and a new whole-file jsdom test
that counts fetches while typing (1 request at init, 0 thereafter; previously
1 -> 2 -> 3 -> 5).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): build pagination via DOM APIs, drop computed member access

Addresses the five Codacy security findings, all in list_filter.js.

Pagination no longer assembles an HTML string (3 findings: 2 critical + 1 high,
"unsafe assignment to innerHTML"). The interpolated values were only page
integers and local class constants, so there was no injection path, but
concatenating markup into innerHTML is the pattern the scanners flag and
createElement is no less clear. Each button now also owns its click listener
directly instead of the container being re-queried afterwards, and the strip is
cleared with textContent = '' rather than by assigning empty markup. No
innerHTML assignment remains in the file.

haystack() now walks Object.entries(item) and keeps the configured fields,
instead of reading item[field] per field ("generic object injection sink").
Field order no longer drives the haystack order, which is irrelevant to the
substring test. matches() iterates controls with for...of instead of an index
("variable assigned to object injection sink").

The rendered pagination is unchanged: same buttons, labels, page numbers,
disabled states and classes. The old-vs-new differential tests now compare
pagination structurally (tag, text, page, disabled, sorted class list) rather
than as an HTML string, since building nodes legitimately serialises
differently — «/» as characters rather than &laquo;/&raquo;, disabled="" rather
than a bare attribute. That comparison is stronger than the string one it
replaces, and the real-DOM suite still drives the actual page buttons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): keep configured field order when building the search haystack

The previous commit swapped item[field] for Object.entries(item) to clear a
static-analysis object-injection warning, and in doing so changed the order of
the haystack: entries follow the object's own key insertion order, not the
configured `fields` order. Since the values are concatenated, that order decides
which values end up adjacent, so a multi-word query spanning a field boundary
matched differently. For store fields [name, description, author, id, ...] and
API objects keyed {id, name, description, author, ...}, "bob plugin-01" matched
before and stopped matching after.

That contradicted the behaviour-preservation claim for the store and starlark
migrations, and the differential tests missed it because every fixture query was
a single word.

Values now come out of a Map built from Object.entries, iterated in `fields`
order: the original haystack is restored, and there is still no computed member
access for the analyser to flag.

Regression coverage for the ordering itself, at both levels:
  - unit: phrases spanning name->id and category->tags, plus the reverse
    (object-key) order asserted NOT to match
  - differential: the same class of query compared old-vs-new, with a guard that
    the phrase actually matches something so a mutual zero-result cannot pass
    vacuously

Verified both fail without this fix (3 unit, 2 differential) and pass with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* test(web): add JS suites for ListFilter and the plugin-manager grids

No JS toolchain exists in this repo, so these are plain node scripts with no
framework: each prints ok/FAIL lines and exits non-zero. `node test/js/run_all.js`
runs everything, skipping the DOM suites (rather than failing) when jsdom is
absent or nothing is listening, so it stays useful in a bare checkout.

  unit/test_list_filter.js    ListFilter search/filter/sort/count/sticky, driven
                              through the installed-plugins config eval'd
                              verbatim out of plugins_manager.js so the test
                              cannot drift from the real configuration
  unit/test_render_cards.js   renderInstalledCards markup, both empty states,
                              and escaping of hostile plugin metadata
  dom/test_installed_dom.js   the toolbar in a real DOM, including the HTMX
                              partial re-swap and a getComputedStyle check that
                              .filter-pill[data-active] matches what we emit
  dom/test_store_dom.js       store pagination, per-page, category, tri-state
                              Installed button, persistence across a re-boot
  dom/test_no_double_fetch.js loads the whole plugins_manager.js and counts
                              requests, so a keystroke cannot refetch the store

The DOM suites deliberately fetch the partial and the plugin data from a running
web interface instead of using fixtures, so a renamed element id or a changed
payload shape fails them loudly. Point them at a rig with a full plugin set when
it matters (BASE=http://host:5000); a dev box with two plugins installed passes
while exercising very little.

Several assertions exist to stop specific bugs recurring: trailing spaces
surviving the search debounce, a query spanning two adjacent search fields
(haystack field order is load-bearing), and window.installedPlugins staying at
full length while the grid is filtered. Others guard against passing vacuously —
counting only non-skeleton cards, and checking a search phrase matches something
before comparing two result sets.

The old-vs-new differential suites that verified the store and starlark
migrations are not included: they compared against the pre-refactor code, which
now exists only in git history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:04:47 -04:00
ChuckandClaude Opus 5 26769ee37f fix(starlark): the store authenticated with a key nothing writes (#541)
#535 restored the thirteen routes, so the store stopped answering 404 --
and still would not load. Confirmed against a running device before
anything was changed: /repository/browse answers 200 with 1000 apps in
27s, so the routes are fine. Two things underneath them are not.

**The store never used the token the user configured.** The three
repository routes read `github_token` off config.json. Nothing writes
that key -- it is not in config.template.json, no setting offers it, and
it appears nowhere else in the codebase. The configured token goes to
config_secrets.json as `github.api_token`, which PluginStoreManager
loads and every other GitHub caller uses. So the store could never be
authenticated: 60 requests/hour, on the same per-IP budget 48 installed
plugins spend on update checks, while the 5000 the user had already
configured sat unused. On the device, /plugins/store/github-status
reported authenticated with a limit of 5000 at the same moment
/starlark/repository/browse reported 60, with 18 left. The store going
blank was that 60 running out.

**Every failure looked identical.** list_all_apps_cached turned any
listing failure -- rate limit, DNS, timeout, non-200 -- into an empty
app list, and the route sent that out as `status: success`, so a rate
limit and an empty repository drew the same blank grid with no error
anywhere. It now returns the reason, the route answers 502 with it, and
a failure is no longer cached as an empty repository for two hours.

The guard for a bad response was itself a crash: _make_request catches
`(json.JSONDecodeError, ValueError)` but `json` was never imported, so
evaluating the tuple raises NameError and the guard written for exactly
this case never ran. Reachable whenever something on the path answers
with HTML -- a captive portal, a proxy page, a DNS-hijacking router.

Seventeen handlers answered 5xx with no detail at all.
test_no_api_v3_handler_discards_its_exception is meant to prevent that
across api_v3, but it matched one exact message string, and all thirteen
Starlark routes wrote their own wording. The guard now keys on the shape
that matters: if it returns 5xx, it says why. The 15 pre-existing
non-Starlark functions are listed as a set that may shrink, never grow.

**The listing was capped at 1000 and did not say so.** The contents API
truncates a directory silently; tronbyt/apps has 1075 app directories,
so the store showed a truncated repository and looked complete doing it.
Now listed via the git trees API, which reports `truncated`, with the
contents API kept as a fallback.

Not addressed: the 27-second cold load -- 1075 manifests fetched five at
a time behind skeleton placeholders -- which is probably the largest part
of what "does not load" feels like, and wants its own change.

25 new tests.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:04:27 -04:00
17 changed files with 2749 additions and 678 deletions
+4
View File
@@ -49,3 +49,7 @@ config/backups/
# Starlark apps runtime storage (installed .star files and cached renders)
/starlark-apps/
skin_renders/
# JS test deps (test/js)
node_modules/
package-lock.json
+112 -28
View File
@@ -5,6 +5,7 @@ Handles interaction with the Tronbyte apps repository on GitHub.
Fetches app listings, metadata, and downloads .star files.
"""
import json
import logging
import time
import requests
@@ -49,6 +50,13 @@ class TronbyteRepository:
self.base_url = "https://api.github.com"
self.raw_url = "https://raw.githubusercontent.com"
# Why the last GitHub API call failed, in words a user can act on.
# _make_request used to log the reason and return a bare None, so
# every caller up the stack knew only that "something" went wrong --
# which is how an exhausted rate limit reached the store page as an
# empty grid with no explanation.
self.last_error: Optional[str] = None
self.session = requests.Session()
if github_token:
self.session.headers.update({
@@ -70,29 +78,50 @@ class TronbyteRepository:
Returns:
JSON response or None on error
"""
self.last_error = None
try:
response = self.session.get(url, timeout=timeout)
if response.status_code == 403:
# Rate limit exceeded
logger.warning("[Tronbyte Repo] GitHub API rate limit exceeded")
if response.status_code in (403, 429):
# 403 is both "rate limited" and "forbidden"; the remaining
# counter is what tells them apart, and the difference matters
# to whoever reads the message -- one is fixed by waiting or
# adding a token, the other is not.
remaining = response.headers.get('X-RateLimit-Remaining')
if remaining == '0':
self.last_error = (
"GitHub API rate limit exceeded"
f" ({response.headers.get('X-RateLimit-Limit', '?')} requests/hour"
f"{'' if self.github_token else ', unauthenticated'})."
" Add a GitHub token in settings, or wait for the limit to reset."
)
else:
self.last_error = f"GitHub refused the request ({response.status_code})"
logger.warning(f"[Tronbyte Repo] {self.last_error}")
return None
elif response.status_code == 404:
self.last_error = "Not found on GitHub"
logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
return None
elif response.status_code != 200:
self.last_error = f"GitHub API error {response.status_code}"
logger.error(f"[Tronbyte Repo] GitHub API error: {response.status_code}")
return None
return response.json()
except requests.Timeout:
self.last_error = "Timed out reaching GitHub"
logger.error(f"[Tronbyte Repo] Request timeout: {url}")
return None
except requests.RequestException as e:
self.last_error = f"Network error reaching GitHub: {e.__class__.__name__}"
logger.error(f"[Tronbyte Repo] Request error: {e}", exc_info=True)
return None
except (json.JSONDecodeError, ValueError) as e:
# Reachable whenever something on the path answers with HTML --
# a captive portal, a proxy error page, a DNS-hijacking router.
self.last_error = "GitHub returned a response that was not JSON"
logger.error(f"[Tronbyte Repo] JSON parse error for {url}: {e}", exc_info=True)
return None
@@ -125,6 +154,61 @@ class TronbyteRepository:
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
return None
def _list_app_dirs_via_trees(self) -> Optional[List[Dict[str, Any]]]:
"""App directories via the git trees API, or None on failure.
The contents API caps a directory listing at 1000 entries and says
nothing about having truncated it, so the store showed the first 1000
apps of a repository that has more and looked complete while doing it.
The trees API caps far higher and sets `truncated` when it does, at
the cost of one extra call to resolve the `apps` tree.
"""
repo = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}"
root = self._make_request(f"{repo}/git/trees/{self.DEFAULT_BRANCH}")
if not isinstance(root, dict):
return None
apps_sha = next(
(e.get('sha') for e in root.get('tree', []) or []
if e.get('path') == self.APPS_PATH and e.get('type') == 'tree'),
None)
if not apps_sha:
self.last_error = f"No '{self.APPS_PATH}' directory in the repository"
return None
tree = self._make_request(f"{repo}/git/trees/{apps_sha}")
if not isinstance(tree, dict):
return None
if tree.get('truncated'):
logger.warning(
"[Tronbyte Repo] GitHub truncated the app tree; the listing is incomplete")
return [
{'id': e['path'], 'path': f"{self.APPS_PATH}/{e['path']}", 'url': None}
for e in tree.get('tree', []) or []
if e.get('type') == 'tree' and e.get('path') and not e['path'].startswith('.')
]
def _list_app_dirs_via_contents(self) -> Optional[List[Dict[str, Any]]]:
"""App directories via the contents API. Capped at 1000 entries."""
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
data = self._make_request(url)
if data is None:
return None
if not isinstance(data, list):
self.last_error = "GitHub returned an unexpected listing format"
return None
return [
{'id': item.get('name'), 'path': item.get('path'), 'url': item.get('url')}
for item in data
if item.get('type') == 'dir' and item.get('name')
and not item['name'].startswith('.')
]
def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]:
"""
List all available apps in the repository.
@@ -132,26 +216,17 @@ class TronbyteRepository:
Returns:
Tuple of (success, apps_list, error_message)
"""
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
data = self._make_request(url)
if data is None:
return False, None, "Failed to fetch repository contents"
if not isinstance(data, list):
return False, None, "Invalid response format"
# Filter directories (apps)
apps = []
for item in data:
if item.get('type') == 'dir':
app_id = item.get('name')
if app_id and not app_id.startswith('.'):
apps.append({
'id': app_id,
'path': item.get('path'),
'url': item.get('url')
})
apps = self._list_app_dirs_via_trees()
if apps is None:
# Fall back rather than fail: the contents API was what shipped,
# so a trees-only outage should not take the store down with it.
trees_error = self.last_error
logger.warning(
f"[Tronbyte Repo] Trees listing failed ({trees_error}); "
"falling back to the contents API")
apps = self._list_app_dirs_via_contents()
if apps is None:
return False, None, self.last_error or trees_error or "Failed to fetch repository contents"
logger.info(f"Found {len(apps)} apps in repository")
return True, apps, None
@@ -267,14 +342,22 @@ class TronbyteRepository:
'categories': _apps_cache['categories'],
'authors': _apps_cache['authors'],
'count': len(_apps_cache['data']),
'cached': True
'cached': True,
'error': None,
}
# Fetch directory listing (1 GitHub API call)
# Fetch directory listing (a small number of GitHub API calls)
success, app_dirs, error = self.list_apps()
if not success or not app_dirs:
logger.error(f"Failed to list apps for bulk fetch: {error}")
return {'apps': [], 'categories': [], 'authors': [], 'count': 0, 'cached': False}
# Returning an empty list here used to read downstream as "the
# repository has no apps", and the route reported that as a
# success -- so a rate limit, a DNS failure and an empty
# repository were all drawn as the same blank grid. Hand the
# reason back instead and let the caller surface it.
reason = error or "No apps found in the repository"
logger.error(f"Failed to list apps for bulk fetch: {reason}")
return {'apps': [], 'categories': [], 'authors': [],
'count': 0, 'cached': False, 'error': reason}
logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...")
@@ -341,7 +424,8 @@ class TronbyteRepository:
'categories': categories,
'authors': authors,
'count': len(apps_with_metadata),
'cached': False
'cached': False,
'error': None,
}
def download_star_file(self, app_id: str, output_path: Path, filename: Optional[str] = None) -> Tuple[bool, Optional[str]]:
+65
View File
@@ -0,0 +1,65 @@
# 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
```bash
cd test/js
npm install # jsdom, for the DOM suites only
node run_all.js
```
The unit suites need nothing but node. 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:
```bash
# 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://10.0.10.169: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.
## 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_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
| `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` |
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` `eval`s 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.
+279
View File
@@ -0,0 +1,279 @@
// Integration test in a REAL DOM (jsdom):
// - HTML comes from the running server's /partials/plugins (real template output)
// - list_filter.js is loaded as a real script
// - plugin data comes from the running server's real API
// - interactions are real dispatched DOM events on the real pill/select nodes
// This exercises HTML parsing, attribute reflection, event bubbling and
// delegation for real — none of which the hand-rolled shim could vouch for.
const fs = require('fs');
const http = require('http');
const { JSDOM, VirtualConsole } = require('jsdom');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const BASE = process.env.BASE || 'http://localhost:5000';
function get(path) {
return new Promise((res, rej) => {
http.get(BASE + path, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); })
.on('error', rej);
});
}
(async () => {
const partial = await get('/partials/plugins');
const installed = JSON.parse(await get('/api/v3/plugins/installed')).data.plugins;
// Collected so an uncaught error inside a handler fails the run instead of
// silently vanishing.
const jsErrors = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => jsErrors.push(String(e.message || e)));
vc.on('error', (...a) => jsErrors.push('console.error: ' + a.join(' ')));
const dom = new JSDOM(
`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/' });
const { window } = dom;
const { document } = window;
// Minimal ambient globals the extracted block expects from plugins_manager.js.
window.pluginLog = () => {};
window.debugLog = () => {};
window.PLUGIN_DEBUG = false;
window.installedPlugins = installed;
window.escapeHtml = function (text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
// Load the helper as a real <script>.
const s = document.createElement('script');
s.textContent = fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8');
document.body.appendChild(s);
if (!window.ListFilter) { console.log('FAIL: list_filter.js did not expose window.ListFilter'); process.exit(1); }
// Pull the installed-plugins wiring out of plugins_manager.js verbatim and run
// it as a real script in this document.
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
const a = src.indexOf('function installedSortName(plugin)');
const b = src.indexOf('// Set up event delegation for plugin action buttons');
const c = src.indexOf('function handlePluginAction(event)');
const d = src.indexOf('function findInstalledPlugin(pluginId)');
if ([a, b, c, d].some(i => i < 0)) { console.log('FAIL: could not slice plugins_manager.js'); process.exit(1); }
const s2 = document.createElement('script');
s2.textContent = `
var installedPlugins = window.installedPlugins;
var escapeHtml = window.escapeHtml, pluginLog = window.pluginLog, debugLog = window.debugLog;
var PLUGIN_DEBUG = false;
${src.slice(a, b)}
${src.slice(b, c)}
${src.slice(c, d)}
window.__t = { getInstalledFilter, renderInstalledPlugins, setupInstalledFilterListeners,
applyInstalledFiltersAndRender };
`;
document.body.appendChild(s2);
if (jsErrors.length) { console.log('FAIL: errors while loading:\n ' + jsErrors.join('\n ')); process.exit(1); }
const T = window.__t;
const $ = id => document.getElementById(id);
const grid = () => $('installed-plugins-grid');
const cards = () => grid().querySelectorAll('.plugin-card:not(.installed-skeleton)').length;
const pill = v => document.querySelector(`#installed-filter-pills [data-installed-filter="${v}"]`);
const litPills = () => [...document.querySelectorAll('#installed-filter-pills [data-installed-filter]')]
.filter(b => b.getAttribute('data-active') === 'true')
.map(b => b.getAttribute('data-installed-filter'));
const countText = () => $('installed-count').textContent.trim();
const click = el => el.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
const type = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('input', { bubbles: true })); };
const change = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('change', { bubbles: true })); };
const tick = ms => new Promise(r => setTimeout(r, ms));
let pass = 0, fail = 0;
const ok = (l, cond, extra) => cond ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')));
console.log('\n── real-DOM integration (jsdom) ──');
console.log(` server gave ${installed.length} installed plugins: ` +
installed.map(p => `${p.id}(en=${p.enabled},upd=${!!p.update_available})`).join(' '));
// ── the markup the server actually rendered ────────────────────────────
ok('toolbar present in server HTML', !!$('installed-filter-bar'));
ok('4 pills parsed from server HTML',
document.querySelectorAll('#installed-filter-pills [data-installed-filter]').length === 4);
ok('sort dropdown has the 5 agreed options',
[...$('installed-sort').options].map(o => o.value).join(',') === 'a-z,z-a,status,recent,category',
[...$('installed-sort').options].map(o => o.value));
ok('skeletons present before first render', grid().querySelectorAll('.installed-skeleton').length === 3);
// ── first render ───────────────────────────────────────────────────────
T.setupInstalledFilterListeners();
T.renderInstalledPlugins(installed);
ok('skeletons removed after render', grid().querySelectorAll('.installed-skeleton').length === 0);
ok('one card per plugin', cards() === installed.length, cards());
ok('count text unfiltered', countText() === `${installed.length} installed`, countText());
ok('only the All pill is lit', litPills().join(',') === 'all', litPills());
ok('aria-pressed mirrors data-active', pill('all').getAttribute('aria-pressed') === 'true');
const enabledCount = installed.filter(p => p.enabled).length;
const disabledCount = installed.length - enabledCount;
const updCount = installed.filter(p => p.update_available).length;
// ── the CSS hook this feature depends on ───────────────────────────────
// .filter-pill[data-active="true"] had never been used by any code before
// this change, so confirm the attribute really flips on the real nodes.
click(pill('enabled'));
ok('Enabled pill lights up', litPills().join(',') === 'enabled', litPills());
ok('Enabled filters the grid', cards() === enabledCount, { cards: cards(), expect: enabledCount });
ok('count switches to "N of M shown"', countText() === `${enabledCount} of ${installed.length} shown`, countText());
ok('Clear button revealed', !$('installed-clear-filters').classList.contains('hidden'));
click(pill('disabled'));
ok('Disabled pill lights, Enabled unlights', litPills().join(',') === 'disabled', litPills());
ok('Disabled filters the grid', cards() === disabledCount, { cards: cards(), expect: disabledCount });
click(pill('updates'));
ok('Updates filters the grid', cards() === updCount, { cards: cards(), expect: updCount });
ok('updates badge count matches', $('installed-updates-count').textContent === String(updCount),
$('installed-updates-count').textContent);
ok('badge visible when >0', updCount === 0 || !$('installed-updates-count').classList.contains('hidden'));
click(pill('all'));
ok('back to All shows everything', cards() === installed.length);
ok('Clear button hidden again', $('installed-clear-filters').classList.contains('hidden'));
// ── debounced search, through the real event path ──────────────────────
const first = installed[0];
type($('installed-search'), first.name.slice(0, 4));
await tick(350);
ok('search narrows to a match', cards() >= 1 && cards() <= installed.length, cards());
ok('search clear ✕ revealed', !$('installed-search-clear').classList.contains('hidden'));
type($('installed-search'), 'zzz-no-such-plugin');
await tick(350);
ok('no matches → zero cards', cards() === 0);
ok('shows the filter empty state', /No plugins match your filters/.test(grid().textContent));
ok('does NOT say "No plugins installed"', !/No plugins installed/.test(grid().textContent));
// Clear button inside the empty state goes through the delegated handler.
const emptyClear = grid().querySelector('[data-action="clear-installed-filters"]');
ok('empty state offers a Clear button', !!emptyClear);
if (emptyClear) {
click(emptyClear);
ok('empty-state Clear restores the grid', cards() === installed.length, cards());
ok('empty-state Clear empties the search box', $('installed-search').value === '');
}
// ── regression: typing a space must survive the debounce ───────────────
// setSearch() trims for matching; if the trimmed value were written back into
// the input, pausing mid-phrase would delete the space you just typed and
// multi-word terms would be untypable.
const si = $('installed-search');
type(si, 'web ');
await tick(350);
ok('trailing space survives the debounce', si.value === 'web ', JSON.stringify(si.value));
type(si, 'web u');
await tick(350);
ok('can keep typing past the space', si.value === 'web u', JSON.stringify(si.value));
type(si, ' ');
await tick(350);
ok('whitespace-only is not treated as an active filter', cards() === installed.length, cards());
ok('whitespace-only text is still left in the box', si.value === ' ', JSON.stringify(si.value));
type(si, '');
await tick(350);
// ── sorts, via the real select ─────────────────────────────────────────
const names = () => [...grid().querySelectorAll('.plugin-card h4')].map(h => h.textContent.trim());
change($('installed-sort'), 'a-z');
const az = names();
change($('installed-sort'), 'z-a');
ok('z-a is the reverse of a-z', names().join('|') === [...az].reverse().join('|'),
{ az, za: names() });
for (const s of ['status', 'recent', 'category']) {
change($('installed-sort'), s);
ok(`sort "${s}" keeps every card`, cards() === installed.length, cards());
}
// ── Clear Filters resets all three axes at once ────────────────────────
type($('installed-search'), first.name.slice(0, 3));
await tick(350);
click(pill('enabled'));
change($('installed-sort'), 'z-a');
click($('installed-clear-filters'));
ok('Clear resets search box', $('installed-search').value === '');
ok('Clear resets sort select', $('installed-sort').value === 'a-z');
ok('Clear relights All', litPills().join(',') === 'all', litPills());
ok('Clear restores all cards', cards() === installed.length, cards());
ok('Clear hides itself', $('installed-clear-filters').classList.contains('hidden'));
// ── the state-leak guard, on a real DOM ────────────────────────────────
click(pill('updates'));
ok('grid is filtered', cards() === updCount);
ok('window.installedPlugins still full length', window.installedPlugins.length === installed.length,
window.installedPlugins.length);
click(pill('all'));
// ── HTMX partial re-swap: the path no unit test could reach ────────────
// Replace the section's markup wholesale (what hx-swap does when you leave
// and re-enter the tab), then re-run init exactly as initPluginsPage would.
click(pill('enabled'));
type($('installed-search'), first.name.slice(0, 3));
await tick(350);
type($('installed-search'), $('installed-search').value + ' '); // trailing space, on purpose
await tick(350);
const beforeSwap = { search: $('installed-search').value, pills: litPills().join(','), cards: cards() };
ok('pre-swap value retains its trailing space', /\s$/.test(beforeSwap.search), JSON.stringify(beforeSwap.search));
document.getElementById('app').innerHTML = partial; // fresh toolbar at defaults
ok('post-swap DOM starts at defaults',
$('installed-search').value === '' && litPills().join(',') === 'all');
T.setupInstalledFilterListeners(); // what initPluginsPage() calls
T.renderInstalledPlugins(window.installedPlugins);
ok('post-swap: search text restored', $('installed-search').value === beforeSwap.search,
{ got: $('installed-search').value, want: beforeSwap.search });
ok('post-swap: pill state restored', litPills().join(',') === beforeSwap.pills, litPills());
// Search AND pill are both still active, so the restored view must match the
// pre-swap view exactly — comparing to it avoids restating the filter logic.
ok('post-swap: same view restored', cards() === beforeSwap.cards,
{ got: cards(), want: beforeSwap.cards });
// and the freshly swapped controls must still be live
type($('installed-search'), '');
await tick(350);
ok('post-swap: clearing search re-widens to the pill alone', cards() === enabledCount, cards());
click(pill('all'));
ok('post-swap: pills still clickable', cards() === installed.length, cards());
click(pill('disabled'));
ok('post-swap: pill filters again', cards() === disabledCount, cards());
click(pill('all'));
type($('installed-search'), 'zzz-none');
await tick(350);
ok('post-swap: search still live', cards() === 0);
click(grid().querySelector('[data-action="clear-installed-filters"]'));
ok('post-swap: delegated Clear still wired', cards() === installed.length);
// ── does the stylesheet actually hook the markup? ──────────────────────
const css = fs.readFileSync(V3 + '/app.css', 'utf8');
const st = document.createElement('style');
st.textContent = css;
document.head.appendChild(st);
click(pill('enabled'));
const activeWeight = window.getComputedStyle(pill('enabled')).fontWeight;
const idleWeight = window.getComputedStyle(pill('disabled')).fontWeight;
ok('.filter-pill[data-active="true"] matches our markup (font-weight 600)',
activeWeight === '600', { active: activeWeight, idle: idleWeight });
ok('inactive pill does not pick up the active rule', idleWeight !== '600', idleWeight);
click(pill('all'));
ok('no uncaught JS errors anywhere', jsErrors.length === 0, jsErrors.slice(0, 4));
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
+132
View File
@@ -0,0 +1,132 @@
// Proves CodeRabbit's second finding is fixed: typing in the store search or
// changing the category must filter the CACHED list, not refetch
// /api/v3/plugins/store/list.
//
// This loads the WHOLE of plugins_manager.js into jsdom (not a slice), so the
// legacy initializePlugins() wiring is present if it still exists, and counts
// every fetch the page makes.
const fs = require('fs');
const http = require('http');
const { JSDOM, VirtualConsole } = require('jsdom');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const BASE = process.env.BASE || 'http://localhost:5000';
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
(async () => {
const partial = await get('/partials/plugins');
const store = JSON.parse(await get('/api/v3/plugins/store/list'));
const installed = JSON.parse(await get('/api/v3/plugins/installed'));
const loadErrors = [];
const vc = new VirtualConsole(); // quiet, but keep real errors
vc.on('jsdomError', e => loadErrors.push(String(e.message || e).split('\n')[0]));
const dom = new JSDOM(`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/', pretendToBeVisual: true });
const { window } = dom;
// Record every request the page attempts; serve from the payloads above.
const calls = [];
window.fetch = (url, opts) => {
const u = String(url);
calls.push(u);
let body = { status: 'success', data: {} };
if (u.includes('/plugins/store/list')) body = store;
else if (u.includes('/plugins/installed')) body = installed;
return Promise.resolve({
ok: true, status: 200,
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
});
};
window.EventSource = function () { return { addEventListener() {}, close() {} }; };
window.confirm = () => false;
window.alert = () => {};
window.scrollTo = () => {};
// Globals plugins_manager.js expects from app.js (it declares /* global debugLog */).
const s0 = window.document.createElement('script');
s0.textContent = `
window.debugLog = function () {};
window.showNotification = function () {};
window.showError = function () {};
window.updateSystemStatus = function () {};
window.Alpine = undefined;
window.htmx = { process: function () {}, ajax: function () {} };
`;
window.document.body.appendChild(s0);
const s = window.document.createElement('script');
s.textContent = fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8');
window.document.body.appendChild(s);
const s2 = window.document.createElement('script');
s2.textContent = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
window.document.body.appendChild(s2);
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 300) : '')));
const tick = ms => new Promise(r => setTimeout(r, ms));
console.log('\n── store search must not refetch (CodeRabbit #2) ──');
if (loadErrors.length) console.log(' load errors: ' + loadErrors.slice(0, 3).join(' | '));
ok('whole plugins_manager.js evaluated', typeof window.initPluginsPage === 'function');
if (typeof window.initPluginsPage === 'function') window.initPluginsPage();
await tick(600);
const storeListCalls = () => calls.filter(u => u.includes('/plugins/store/list')).length;
const afterInit = storeListCalls();
console.log(` init issued ${afterInit} store/list request(s) — that part is expected`);
ok('init loaded the store at least once', afterInit >= 1, calls);
const search = window.document.getElementById('plugin-search');
const category = window.document.getElementById('plugin-category');
ok('store search input present', !!search);
// Type a realistic burst, then wait past both debounce windows (300 ms each).
const before = storeListCalls();
for (const v of ['w', 'we', 'wea', 'weat', 'weath', 'weathe', 'weather']) {
search.value = v;
search.dispatchEvent(new window.Event('input', { bubbles: true }));
await tick(40);
}
await tick(700);
ok('typing 7 characters issued ZERO new store/list fetches',
storeListCalls() === before, { before, after: storeListCalls(), calls: calls.slice(-4) });
const beforeCat = storeListCalls();
if (category) {
category.value = 'sports';
category.dispatchEvent(new window.Event('change', { bubbles: true }));
await tick(700);
ok('changing category issued ZERO new store/list fetches',
storeListCalls() === beforeCat, { before: beforeCat, after: storeListCalls() });
}
// ...and the filtering still actually happened.
// Clear both axes before counting: 'weather' AND category=sports legitimately
// matches nothing, so counting here would measure the filter, not the render.
const beforeClear = storeListCalls();
search.value = '';
search.dispatchEvent(new window.Event('input', { bubbles: true }));
if (category) { category.value = ''; category.dispatchEvent(new window.Event('change', { bubbles: true })); }
await tick(700);
ok('clearing the filters also issued ZERO fetches', storeListCalls() === beforeClear,
{ before: beforeClear, after: storeListCalls() });
// Count only real cards: the partial ships loading skeletons that also carry
// .plugin-card, which would let this pass on a dead page.
const grid = window.document.getElementById('plugin-store-grid');
const real = grid.querySelectorAll('.plugin-card:not(.animate-pulse)').length;
ok('the grid rendered real cards from the cache', real > 0, { real, html: grid.innerHTML.slice(0, 160) });
ok('no leftover _listenerSetup flag on the search input',
search && search._listenerSetup === undefined, search && search._listenerSetup);
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 6).join('\n')); process.exit(1); });
+221
View File
@@ -0,0 +1,221 @@
// Real-DOM (jsdom) test of the migrated Plugin Store toolbar, against the
// server's actual 48-plugin registry — enough data for 4 pages, so pagination,
// the ellipsis strip and per-page changes are exercised for real.
// The card renderer itself is stubbed: Step 2 did not touch it, and stubbing
// keeps the assertions on the parts that did change.
const fs = require('fs');
const http = require('http');
const { JSDOM, VirtualConsole } = require('jsdom');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const BASE = process.env.BASE || 'http://localhost:5000';
const get = path => new Promise((res, rej) =>
http.get(BASE + path, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
function slice(src, a, b) {
const i = src.indexOf(a), j = src.indexOf(b, i + 1);
if (i < 0 || j < 0) throw new Error('cannot slice: ' + a);
return src.slice(i, j);
}
(async () => {
const partial = await get('/partials/plugins');
const storeJson = JSON.parse(await get('/api/v3/plugins/store/list'));
const storePlugins = (storeJson.data && storeJson.data.plugins) || storeJson.plugins || [];
const installed = JSON.parse(await get('/api/v3/plugins/installed')).data.plugins;
const jsErrors = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => jsErrors.push(String(e.message || e)));
const dom = new JSDOM(`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/' });
const { window } = dom;
const { document } = window;
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
function boot() {
const s = document.createElement('script');
// IIFE-wrapped so a second boot gets a fresh closure instead of clashing on
// the top-level `const ListFilter`; it still publishes via window.ListFilter.
s.textContent = '(function(){\n' + fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8') + '\n})();';
document.body.appendChild(s);
const s2 = document.createElement('script');
s2.textContent = `(function(){
${slice(src, 'const safeLocalStorage = {', '\n// ')}
var installedPlugins = ${JSON.stringify(installed)};
window.installedPlugins = installedPlugins;
var pluginStoreCache = ${JSON.stringify(storePlugins)};
window.__renderCalls = 0;
// Stand-in for renderPluginStore: real DOM nodes, minimal markup.
function renderPluginStore(plugins) {
window.__renderCalls++;
const grid = document.getElementById('plugin-store-grid');
grid.innerHTML = (plugins || []).map(p =>
'<div class="plugin-card" data-id="' + p.id + '"></div>').join('');
}
${slice(src, 'function isStorePluginInstalled(', '// Expose searchPluginStore')}
window.__t = { applyStoreFiltersAndSort, setupStoreFilterListeners, getStoreFilter };
})();`;
document.body.appendChild(s2);
}
boot();
if (jsErrors.length) { console.log('FAIL loading:\n ' + jsErrors.join('\n ')); process.exit(1); }
const T = window.__t;
const $ = id => document.getElementById(id);
const cards = () => $('plugin-store-grid').querySelectorAll('.plugin-card').length;
const ids = () => [...$('plugin-store-grid').querySelectorAll('.plugin-card')].map(c => c.dataset.id);
const info = () => $('store-results-info').textContent.trim();
const infoBot = () => $('store-results-info-bottom') ? $('store-results-info-bottom').textContent.trim() : null;
const pagTop = () => $('store-pagination-top').innerHTML;
const pageBtns = () => [...$('store-pagination-top').querySelectorAll('[data-list-page]')];
const clickPage = label => {
const b = pageBtns().find(x => x.textContent.trim() === String(label));
if (!b) throw new Error('no page button labelled ' + label);
b.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
};
const click = el => el.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
const change = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('change', { bubbles: true })); };
const type = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('input', { bubbles: true })); };
const tick = ms => new Promise(r => setTimeout(r, ms));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 240) : '')));
console.log(`\n── store, real DOM, ${storePlugins.length} plugins from the live registry ──`);
T.setupStoreFilterListeners();
T.applyStoreFiltersAndSort();
const N = storePlugins.length;
ok('page 1 holds 12 cards', cards() === 12, cards());
ok('results info counts all ' + N, info() === `Showing 1–12 of ${N} plugins`, info());
ok('bottom info matches top', infoBot() === info(), { top: info(), bottom: infoBot() });
ok('pagination rendered', pageBtns().length > 0);
ok('per-page control shows 12', $('store-per-page').value === '12');
const totalPages = Math.ceil(N / 12);
ok(`last page button is ${totalPages}`,
pageBtns().some(b => b.textContent.trim() === String(totalPages)), pagTop().slice(0, 200));
ok('prev is disabled on page 1', pageBtns()[0].hasAttribute('disabled'));
const p1 = ids();
clickPage(2);
ok('page 2 renders 12 different cards', cards() === 12 && ids().join() !== p1.join());
ok('page 2 info', info() === `Showing 13–24 of ${N} plugins`, info());
clickPage(totalPages);
ok('last page info ends at ' + N, new RegExp(`–${N} of ${N} plugins$`).test(info()), info());
ok('next is disabled on the last page',
pageBtns()[pageBtns().length - 1].hasAttribute('disabled'));
ok('ellipsis appears for a far page', /&hellip;|…/.test(pagTop()), pagTop().slice(0, 120));
clickPage(1);
ok('back on page 1 shows the original slice', ids().join() === p1.join());
// ── per-page ───────────────────────────────────────────────────────────
change($('store-per-page'), '48');
ok('per-page 48 shows all in one page', cards() === Math.min(48, N), cards());
ok('pagination hidden when one page', pagTop().trim() === '' || pageBtns().length === 0, pagTop().slice(0, 80));
ok('per-page persisted', window.localStorage.getItem('storePerPage') === '48');
change($('store-per-page'), '12');
ok('back to 12 restores pagination', cards() === 12 && pageBtns().length > 0);
// ── sorts ──────────────────────────────────────────────────────────────
const firstOf = () => ids()[0];
change($('store-sort'), 'a-z');
const azFirst = firstOf();
change($('store-sort'), 'z-a');
ok('z-a changes the leading card', firstOf() !== azFirst, { az: azFirst, za: firstOf() });
ok('sort persisted to localStorage', window.localStorage.getItem('storeSort') === 'z-a');
for (const s of ['category', 'author', 'newest']) {
change($('store-sort'), s);
ok(`sort "${s}" still fills a page`, cards() === 12, cards());
}
change($('store-sort'), 'a-z');
// ── category ───────────────────────────────────────────────────────────
const cat = [...$('plugin-category').options].map(o => o.value).find(v => v && storePlugins.some(p => (p.category || '').toLowerCase() === v.toLowerCase()));
if (cat) {
change($('plugin-category'), cat);
const expect = storePlugins.filter(p => (p.category || '').toLowerCase() === cat.toLowerCase()).length;
ok(`category "${cat}" filters to ${expect}`, cards() === Math.min(expect, 12), { cards: cards(), expect });
ok('badge reports active filters', /filter/.test($('store-active-filters').textContent),
$('store-active-filters').textContent);
change($('plugin-category'), '');
ok('category cleared restores', cards() === 12);
} else { console.log(' -- no usable category option in the template, skipped'); }
// ── tri-state installed button ─────────────────────────────────────────
const instBtn = $('store-filter-installed');
ok('starts as All', /All/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-white'), instBtn.innerHTML);
click(instBtn);
ok('→ Installed, green', /Installed/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-green-50')
&& !instBtn.classList.contains('bg-white'), instBtn.innerHTML);
const instCount = storePlugins.filter(p => installed.some(i => i.id === p.id ||
(p.plugin_path && i.id === p.plugin_path.split('/').pop()))).length;
ok('Installed count matches the installed set', cards() === Math.min(instCount, 12), { cards: cards(), expect: instCount });
click(instBtn);
ok('→ Not Installed, red', /Not Installed/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-red-50'), instBtn.innerHTML);
ok('Not Installed count is the complement', cards() === Math.min(N - instCount, 12), { cards: cards(), expect: N - instCount });
click(instBtn);
ok('→ back to All', /All/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-white'));
// ── search ─────────────────────────────────────────────────────────────
const term = (storePlugins[0].name || storePlugins[0].id).slice(0, 4);
type($('plugin-search'), term);
await tick(400);
ok(`search "${term}" narrows the set`, cards() > 0 && cards() <= 12, cards());
type($('plugin-search'), 'zzz-definitely-nothing');
await tick(400);
ok('no matches → 0 cards', cards() === 0);
ok('no-match info text', info() === 'No plugins match your filters', info());
type($('plugin-search'), '');
await tick(400);
ok('search cleared restores', cards() === 12);
// ── clear filters ──────────────────────────────────────────────────────
change($('store-per-page'), '24');
change($('store-sort'), 'z-a');
if (cat) change($('plugin-category'), cat);
click(instBtn);
type($('plugin-search'), term);
await tick(400);
ok('clear button visible with filters active', !$('store-clear-filters').classList.contains('hidden'));
click($('store-clear-filters'));
ok('clear resets search', $('plugin-search').value === '');
ok('clear resets sort to a-z', $('store-sort').value === 'a-z');
ok('clear resets category', $('plugin-category').value === '');
ok('clear resets installed button to All', /All/.test(instBtn.innerHTML));
ok('clear PRESERVES per-page 24', $('store-per-page').value === '24', $('store-per-page').value);
ok('clear shows a full 24-card page', cards() === 24, cards());
ok('clear hides itself', $('store-clear-filters').classList.contains('hidden'));
// ── persistence across a reload ────────────────────────────────────────
change($('store-sort'), 'author');
change($('store-per-page'), '48');
const saved = { sort: window.localStorage.getItem('storeSort'), per: window.localStorage.getItem('storePerPage') };
ok('prefs written under the original keys', saved.sort === 'author' && saved.per === '48', saved);
// Re-swap the partial (fresh controls at defaults) and re-boot the module, as
// a page reload would.
document.getElementById('app').innerHTML = partial;
ok('fresh markup starts at defaults',
$('store-sort').value === 'a-z' && $('store-per-page').value === '12');
const controllerBefore = window.__t;
boot(); // fresh module instances, fresh localStorage read
ok('re-boot produced a new module instance', window.__t !== controllerBefore);
window.__t.setupStoreFilterListeners();
window.__t.applyStoreFiltersAndSort();
ok('reload restores sort=author into the control', $('store-sort').value === 'author', $('store-sort').value);
ok('reload restores per-page=48 into the control', $('store-per-page').value === '48', $('store-per-page').value);
ok('reload renders with the restored page size', cards() === Math.min(48, N), cards());
ok('no uncaught JS errors', jsErrors.length === 0, jsErrors.slice(0, 3));
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
+12
View File
@@ -0,0 +1,12 @@
{
"name": "ledmatrix-webui-js-tests",
"version": "1.0.0",
"private": true,
"description": "JS tests for the v3 web interface (ListFilter and the plugin-manager grids)",
"scripts": {
"test": "node run_all.js"
},
"devDependencies": {
"jsdom": "^24.1.3"
}
}
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
// Runs the web-interface JS suites.
//
// node run_all.js unit suites, plus DOM suites if a
// web interface is reachable
// BASE=http://10.0.10.169:5000 node run_all.js point the DOM suites at a rig
//
// Unit suites need nothing but node. The DOM suites need `npm install` (jsdom)
// and a running web interface, because they deliberately test against the real
// server-rendered HTML and the real API rather than fixtures.
const { spawnSync } = require('child_process');
const http = require('http');
const path = require('path');
const fs = require('fs');
const BASE = process.env.BASE || 'http://localhost:5000';
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js'];
function reachable(url) {
return new Promise(res => {
const req = http.get(url, r => { r.resume(); res(r.statusCode < 500); });
req.on('error', () => res(false));
req.setTimeout(4000, () => { req.destroy(); res(false); });
});
}
function run(file) {
const r = spawnSync(process.execPath, [path.join(__dirname, file)],
{ stdio: 'inherit', cwd: __dirname, env: process.env });
return r.status === 0;
}
(async () => {
const results = [];
for (const f of UNIT) results.push([f, run(f)]);
const haveJsdom = fs.existsSync(path.join(__dirname, 'node_modules', 'jsdom'));
const up = await reachable(BASE + '/');
if (!haveJsdom) {
console.log(`\nSKIPPING DOM suites: jsdom not installed (run: npm install)\n`);
} else if (!up) {
console.log(`\nSKIPPING DOM suites: no web interface reachable at ${BASE}`);
console.log(` start one with: EMULATOR=true python3 web_interface/app.py`);
console.log(` or point at a rig: BASE=http://<host>:5000 node run_all.js\n`);
} else {
console.log(`\nDOM suites against ${BASE}\n`);
for (const f of DOM) results.push([f, run(f)]);
}
const failed = results.filter(([, ok]) => !ok);
console.log('\n─── summary ───');
results.forEach(([f, ok]) => console.log(` ${ok ? 'PASS' : 'FAIL'} ${f}`));
if (failed.length) { console.log(`\n${failed.length} suite(s) failed\n`); process.exit(1); }
console.log('\nall suites passed\n');
})();
+288
View File
@@ -0,0 +1,288 @@
// Functional test for ListFilter + the installed-plugins config extracted
// verbatim from plugins_manager.js. Runs under node with a minimal DOM shim.
const fs = require('fs');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
// ── minimal DOM shim ───────────────────────────────────────────────────────
class El {
constructor(id, attrs = {}) {
this.id = id; this._attrs = { ...attrs }; this.value = '';
this.textContent = ''; this.innerHTML = ''; this.title = '';
this._classes = new Set(); this.children = []; this._listeners = {};
this.parentEl = null;
const self = this;
this.classList = {
add: (...c) => c.forEach(x => self._classes.add(x)),
remove: (...c) => c.forEach(x => self._classes.delete(x)),
contains: c => self._classes.has(c),
toggle: (c, force) => {
const on = force === undefined ? !self._classes.has(c) : !!force;
if (on) self._classes.add(c); else self._classes.delete(c);
return on;
},
};
}
setAttribute(k, v) { this._attrs[k] = String(v); }
getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; }
addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); }
fire(type, target) {
// Real DOM listeners get `this` === the element the listener is bound to;
// the sort/select handlers rely on that, so mirror it.
(this._listeners[type] || []).forEach(fn =>
fn.call(this, { target: target || this, currentTarget: this, key: undefined,
preventDefault() {}, stopPropagation() {} }));
}
append(child) { child.parentEl = this; this.children.push(child); return child; }
querySelectorAll(sel) {
const m = /^\[([^\]=]+)\]$/.exec(sel);
if (m) return this.children.filter(c => c.getAttribute(m[1]) !== null);
return [];
}
contains(node) { return node === this || this.children.includes(node); }
closest(sel) {
const m = /^\[([^\]=]+)\]$/.exec(sel);
let n = this;
while (n) { if (m && n.getAttribute(m[1]) !== null) return n; n = n.parentEl; }
return null;
}
}
const registry = new Map();
function mk(id, attrs) { const e = new El(id, attrs); if (id) registry.set(id, e); return e; }
global.document = {
getElementById: id => registry.get(id) || null,
querySelector: sel => {
let m = /^#([\w-]+)$/.exec(sel);
if (m) return registry.get(m[1]) || null;
m = /^#([\w-]+)\s+\[([\w-]+)="([^"]+)"\]$/.exec(sel);
if (m) {
const parent = registry.get(m[1]);
if (!parent) return null;
return parent.children.find(c => c.getAttribute(m[2]) === m[3]) || null;
}
return null;
},
dispatchEvent() {}, addEventListener() {},
};
global.CustomEvent = class { constructor(t, o) { this.type = t; Object.assign(this, o); } };
global.window = global;
// ── build the toolbar exactly as plugins.html declares it ─────────────────
mk('installed-search');
mk('installed-search-clear');
mk('installed-sort');
mk('installed-clear-filters');
mk('installed-count');
mk('installed-updates-count');
mk('installed-plugins-grid');
const pills = mk('installed-filter-pills');
['all', 'enabled', 'disabled', 'updates'].forEach(v => {
const b = new El(null, { 'data-installed-filter': v });
pills.append(b);
});
document.getElementById('installed-sort').value = 'a-z';
// ── load the helper ───────────────────────────────────────────────────────
const ListFilter = require(path.join(V3, 'js/plugins/list_filter.js'));
global.ListFilter = ListFilter;
// ── extract the installed-plugins config verbatim from plugins_manager.js ──
const src = fs.readFileSync(path.join(V3, 'plugins_manager.js'), 'utf8');
const start = src.indexOf('function installedSortName(plugin)');
const endMark = 'function setupInstalledFilterListeners()';
const end = src.indexOf(endMark);
if (start < 0 || end < 0) { console.error('FAIL: could not locate installed filter block'); process.exit(1); }
const block = src.slice(start, end);
// deps the block expects from its enclosing IIFE
let rendered = null;
global.installedPlugins = [];
global.pluginLog = () => {};
function renderInstalledCards(list, total) { rendered = { list, total }; }
eval(block); // defines installedSortName/comparators/getInstalledFilter/etc.
// ── fixture ───────────────────────────────────────────────────────────────
const P = (id, o) => Object.assign(
{ id, name: id, enabled: true, update_available: false, category: 'general',
description: '', author: 'someone', tags: [], version: '1.0.0', last_updated: null }, o);
const FIXTURE = [
P('zulu-clock', { name: 'Zulu Clock', enabled: true, category: 'time', last_updated: '2026-01-05' }),
P('alpha-weather', { name: 'Alpha Weather', enabled: false, category: 'weather', last_updated: '2026-03-11', update_available: true, latest_version: '2.0.0' }),
P('mid-stocks', { name: 'Mid Stocks', enabled: true, category: 'financial', last_updated: null, update_available: true, latest_version: '3.1.0' }),
P('no-date', { name: 'No Date Here', enabled: false, category: 'general', last_updated: null }),
P('taggy', { name: 'Taggy', enabled: true, category: 'media', last_updated: '2026-06-30', tags: ['hockey', 'nhl'] }),
P('bad-date', { name: 'Bad Date', enabled: true, category: 'general', last_updated: 'not-a-date' }),
];
global.installedPlugins = FIXTURE;
window.installedPlugins = FIXTURE;
const ctl = getInstalledFilter();
if (!ctl) { console.error('FAIL: controller not created'); process.exit(1); }
ctl.bind();
// ── assertions ────────────────────────────────────────────────────────────
let pass = 0, fail = 0;
function ok(label, cond, extra) {
if (cond) { pass++; console.log(' ok ' + label); }
else { fail++; console.log(' FAIL ' + label + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')); }
}
const ids = () => rendered.list.map(p => p.id);
const countText = () => document.getElementById('installed-count').textContent;
const badge = () => document.getElementById('installed-updates-count');
const pillOf = v => pills.children.find(c => c.getAttribute('data-installed-filter') === v);
const setPill = v => pills.fire('click', pillOf(v));
console.log('\n1. default state (no filters)');
ctl.apply();
ok('renders all 6', rendered.list.length === 6, ids());
ok('total is 6', rendered.total === 6);
ok('sorted A→Z', ids().join(',') === 'alpha-weather,bad-date,mid-stocks,no-date,taggy,zulu-clock', ids());
ok('count reads "6 installed"', countText() === '6 installed', countText());
ok('updates badge shows 2', badge().textContent === '2' && !badge().classList.contains('hidden'), badge().textContent);
ok('clear button hidden', document.getElementById('installed-clear-filters').classList.contains('hidden'));
console.log('\n2. pill: enabled / disabled partition');
setPill('enabled');
const enabledIds = ids();
ok('enabled → 4', enabledIds.length === 4, enabledIds);
ok('count reads "4 of 6 shown"', countText() === '4 of 6 shown', countText());
ok('clear button visible', !document.getElementById('installed-clear-filters').classList.contains('hidden'));
ok('enabled pill lit', pillOf('enabled').getAttribute('data-active') === 'true');
ok('all pill unlit', pillOf('all').getAttribute('data-active') === 'false');
ok('aria-pressed set', pillOf('enabled').getAttribute('aria-pressed') === 'true');
setPill('disabled');
const disabledIds = ids();
ok('disabled → 2', disabledIds.length === 2, disabledIds);
ok('partition is exact, no overlap/loss',
enabledIds.length + disabledIds.length === 6 &&
!enabledIds.some(i => disabledIds.includes(i)));
console.log('\n3. pill: updates');
setPill('updates');
ok('updates → only update_available', ids().join(',') === 'alpha-weather,mid-stocks', ids());
ok('badge count unaffected by filtering', badge().textContent === '2');
console.log('\n4. search');
setPill('all');
const searchEl = document.getElementById('installed-search');
function search(text) { searchEl.value = text; ctl.setSearch(text); }
search('weather');
ok('name match', ids().join(',') === 'alpha-weather', ids());
search('WEATHER');
ok('case-insensitive', ids().join(',') === 'alpha-weather', ids());
search('nhl');
ok('matches tags[]', ids().join(',') === 'taggy', ids());
search('financial');
ok('matches category', ids().join(',') === 'mid-stocks', ids());
search('someone');
ok('matches author → all 6', ids().length === 6);
search(' zulu ');
ok('trims whitespace', ids().join(',') === 'zulu-clock', ids());
search('zzzznope');
ok('no match → empty list, total preserved', rendered.list.length === 0 && rendered.total === 6);
ok('count reads "0 of 6 shown"', countText() === '0 of 6 shown', countText());
// The haystack joins fields in the CONFIGURED order (name, id, description,
// author, category, tags), so a query may straddle a field boundary. The
// fixtures deliberately insert `id` before `name`, so reading the object's own
// entry order instead would break this.
search('zulu clock zulu-clock');
ok('phrase spanning name→id matches (field order preserved)',
ids().join(',') === 'zulu-clock', ids());
search('zulu-clock zulu clock');
ok('the reverse (object key order) does NOT match', rendered.list.length === 0, ids());
search('taggy media');
ok('phrase spanning category→tags respects field order', ids().length === 0, ids());
search('');
ok('cleared → all 6 back', ids().length === 6);
ok('count back to "6 installed"', countText() === '6 installed', countText());
console.log('\n5. search + pill combine (AND)');
setPill('enabled');
search('a');
const both = rendered.list;
ok('all results enabled', both.every(p => p.enabled), ids());
ok('all results match "a"', both.every(p => (p.name + p.id + p.category + p.author).toLowerCase().includes('a')));
search('');
setPill('all');
console.log('\n6. sorts');
const sortEl = document.getElementById('installed-sort');
function sort(v) { sortEl.value = v; sortEl.fire('change'); }
sort('z-a');
ok('z-a reverses', ids().join(',') === 'zulu-clock,taggy,no-date,mid-stocks,bad-date,alpha-weather', ids());
sort('status');
const st = ids();
ok('status: updates first', st.slice(0, 2).sort().join(',') === 'alpha-weather,mid-stocks', st);
ok('status: disabled last', st[st.length - 1] === 'no-date', st);
sort('recent');
const rec = ids();
ok('recent: newest first', rec[0] === 'taggy' && rec[1] === 'alpha-weather' && rec[2] === 'zulu-clock', rec);
ok('recent: missing/unparseable dates last',
['bad-date', 'mid-stocks', 'no-date'].every(i => rec.indexOf(i) >= 3), rec);
ok('recent: nothing dropped', rec.length === 6);
sort('category');
const cat = ids();
ok('category groups', cat.join(',') === 'mid-stocks,bad-date,no-date,taggy,zulu-clock,alpha-weather', cat);
sort('a-z');
console.log('\n7. clear filters');
search('taggy'); setPill('updates'); sort('z-a');
ok('3 axes active', ctl.activeCount() === 3, ctl.activeCount());
document.getElementById('installed-clear-filters').fire('click');
ok('reset → all 6', ids().length === 6, ids());
ok('reset clears search input', searchEl.value === '');
ok('reset restores sort to a-z', ctl.state.sort === 'a-z' && sortEl.value === 'a-z');
ok('reset relights All pill', pillOf('all').getAttribute('data-active') === 'true');
ok('activeCount back to 0', ctl.activeCount() === 0);
console.log('\n8. sticky: the just-toggled card must not vanish');
setPill('enabled');
ok('zulu-clock visible while enabled', ids().includes('zulu-clock'));
ctl.sticky.add('zulu-clock');
FIXTURE[0].enabled = false; // simulate the toggle landing
ctl.apply();
ok('still visible after being disabled', ids().includes('zulu-clock'), ids());
ok('but total unchanged', rendered.total === 6);
setPill('enabled'); // user touches toolbar → sticky clears
ok('sticky cleared on toolbar use', !ids().includes('zulu-clock'), ids());
FIXTURE[0].enabled = true;
setPill('all');
console.log('\n9. state-leak guard: window.installedPlugins must stay whole');
search('taggy');
ok('grid shows 1', rendered.list.length === 1);
ok('window.installedPlugins still 6', window.installedPlugins.length === 6, window.installedPlugins.length);
search('');
console.log('\n10. updates badge when nothing needs updating');
const noUpd = FIXTURE.map(p => ({ ...p, update_available: false }));
window.installedPlugins = noUpd; global.installedPlugins = noUpd;
ctl.apply();
ok('badge hidden at 0', badge().classList.contains('hidden'));
ok('updates pill dimmed', pillOf('updates').classList.contains('opacity-50'));
ok('pill title explains', /No plugins have updates/.test(pillOf('updates').title), pillOf('updates').title);
window.installedPlugins = FIXTURE; global.installedPlugins = FIXTURE;
ctl.apply();
ok('badge back at 2', badge().textContent === '2' && !badge().classList.contains('hidden'));
ok('pill undimmed', !pillOf('updates').classList.contains('opacity-50'));
console.log('\n11. empty installed list');
window.installedPlugins = []; global.installedPlugins = [];
ctl.apply();
ok('renders nothing, total 0', rendered.list.length === 0 && rendered.total === 0);
ok('count reads "0 installed"', countText() === '0 installed', countText());
console.log('\n12. defensive: missing fields');
const ragged = [{ id: 'bare' }, { id: 'nulls', name: null, tags: null, category: null, last_updated: null }];
window.installedPlugins = ragged; global.installedPlugins = ragged;
sort('recent'); ctl.apply();
ok('no throw on ragged records, both rendered', rendered.list.length === 2, ids());
search('bare');
ok('search works on ragged records', ids().join(',') === 'bare', ids());
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
+109
View File
@@ -0,0 +1,109 @@
// Verifies renderInstalledCards (extracted verbatim from plugins_manager.js):
// the two empty states, the card markup, and that it never publishes state.
const fs = require('fs');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
function slice(fromMark, toMark) {
const a = src.indexOf(fromMark);
const b = src.indexOf(toMark, a + 1);
if (a < 0 || b < 0) { console.error('FAIL: cannot locate ' + fromMark); process.exit(1); }
return src.slice(a, b);
}
const container = {
innerHTML: '',
querySelectorAll: () => [], // no skeletons in this harness
};
// escapeHtml() escapes via a detached element, so mirror what a browser does
// when you read innerHTML back off textContent: & < > are escaped, quotes are not.
class FakeEl {
set textContent(v) { this._t = String(v == null ? '' : v); }
get textContent() { return this._t || ''; }
get innerHTML() {
return (this._t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
}
global.document = {
getElementById: id => (id === 'installed-plugins-grid' ? container : null),
createElement: () => new FakeEl(),
};
global.window = global;
global.pluginLog = () => {};
global.PLUGIN_DEBUG = false;
global.debugLog = () => {};
function setupInstalledEventDelegation() {} // stubbed; tested separately
eval(slice('function escapeHtml(text)', '\nfunction ', ));
eval(slice('function renderInstalledCards(plugins, total)',
'// Set up event delegation for plugin action buttons'));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 300) : '')));
const SENTINEL = ['do', 'not', 'touch'];
window.installedPlugins = SENTINEL;
console.log('\n1. no plugins installed at all');
renderInstalledCards([], 0);
ok('shows "No plugins installed"', /No plugins installed/.test(container.innerHTML));
ok('does NOT show the filter empty state', !/No plugins match/.test(container.innerHTML));
ok('uses the plug icon', /fa-plug/.test(container.innerHTML));
console.log('\n2. plugins installed but none match the filters');
renderInstalledCards([], 12);
ok('shows "No plugins match your filters"', /No plugins match your filters/.test(container.innerHTML));
ok('does NOT claim nothing is installed', !/No plugins installed/.test(container.innerHTML));
ok('reports the installed total', /12 plugins installed/.test(container.innerHTML), container.innerHTML.match(/\d+ plugins? installed/));
ok('offers a Clear filters button', /data-action="clear-installed-filters"/.test(container.innerHTML));
ok('uses the filter icon', /fa-filter/.test(container.innerHTML));
console.log('\n3. singular/plural of the installed total');
renderInstalledCards([], 1);
ok('"1 plugin installed" (singular)', /1 plugin installed/.test(container.innerHTML) && !/1 plugins/.test(container.innerHTML));
console.log('\n4. real cards');
const plugins = [
{ id: 'alpha', name: 'Alpha', author: 'Ann', version: '1.0.0', category: 'weather',
description: 'Shows weather', enabled: true, tags: ['a', 'b'], verified: true },
{ id: 'beta', name: 'Beta', author: 'Bob', version: '1.0.0', latest_version: '2.0.0',
update_available: true, category: 'time', description: 'Clock', enabled: false },
];
renderInstalledCards(plugins, 5);
const html = container.innerHTML;
ok('renders 2 cards', (html.match(/class="plugin-card"/g) || []).length === 2);
ok('enabled card shows Enabled', /<span>Enabled<\/span>/.test(html));
ok('disabled card shows Disabled', /<span>Disabled<\/span>/.test(html));
ok('update badge shows target version', /v2\.0\.0 available/.test(html));
ok('update button labelled "Update to v2.0.0"', /Update to v2\.0\.0/.test(html));
ok('pulsing ring class on the outdated one', /plugin-update-available/.test(html));
ok('verified badge present', /Verified/.test(html));
ok('tags rendered', /badge-info">a</.test(html) && /badge-info">b</.test(html));
ok('per-plugin action hooks present',
/data-action="configure"/.test(html) && /data-action="update"/.test(html) &&
/data-action="uninstall"/.test(html) && /data-action="toggle"/.test(html));
ok('no empty state alongside cards', !/empty-state/.test(html));
console.log('\n5. it must not publish state');
ok('window.installedPlugins untouched', window.installedPlugins === SENTINEL, window.installedPlugins);
console.log('\n6. total defaults to list length when omitted');
renderInstalledCards([], undefined);
ok('omitted total + empty list → "no plugins installed"', /No plugins installed/.test(container.innerHTML));
console.log('\n7. XSS: hostile plugin metadata is escaped');
renderInstalledCards([{
id: 'evil', name: '<img src=x onerror=alert(1)>', author: '"><script>bad()</script>',
version: '1.0.0', category: 'x', description: '<b>nope</b>', enabled: true, tags: ['<i>t</i>'],
}], 1);
const evil = container.innerHTML;
ok('no raw <img', !/<img src=x/.test(evil));
ok('no raw <script', !/<script>bad/.test(evil));
ok('no raw <b> from description', !/<b>nope<\/b>/.test(evil));
ok('no raw <i> from tags', !/<i>t<\/i>/.test(evil));
ok('escaped entities present instead', /&lt;/.test(evil));
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
+54 -4
View File
@@ -116,7 +116,56 @@ class TestHandlersCarryDetail:
src = open("web_interface/blueprints/api_v3.py").read()
tree = ast.parse(src)
generic = "An error occurred; see logs for details"
# This used to match one exact message string, so a handler that wrote
# its own wording was never checked. All thirteen Starlark routes did
# -- "Failed to browse repository" and friends -- and every one of them
# answered a 500 with no detail at all, which is how the app store
# spent three releases failing for reasons nobody could read. The rule
# is now the shape that matters: if it returns 5xx, it says why.
PRE_EXISTING = {
# Not part of this change. This set may shrink, never grow.
'backup_delete', 'backup_export', 'backup_list', 'backup_preview',
'backup_restore', 'backup_validate', 'checkout_branch',
'execute_system_action', 'get_git_branches', 'get_git_info',
'get_hardware_status', 'get_logs', 'get_system_status',
'get_system_version', 'scan_wifi_networks',
}
def enclosing_function(handler):
"""Innermost function containing `handler`."""
best = None
for fn in [n for n in ast.walk(tree)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]:
if any(h is handler for h in ast.walk(fn)):
if best is None or fn.lineno > best.lineno:
best = fn
return best.name if best else '<module>'
def only_catches_importerror(handler):
"""An `except ImportError` arm and nothing else.
A missing optional dependency is a configuration fact, not a
crash: the module name is the whole diagnosis and it is already
in the response, so a stack trace would be noise. Detail is still
required -- only the traceback log is excused.
"""
t = handler.type
names = ([t] if isinstance(t, ast.Name)
else list(t.elts) if isinstance(t, ast.Tuple) else [])
return bool(names) and all(
isinstance(n, ast.Name) and n.id == 'ImportError' for n in names)
def returns_5xx(handler):
for r in [n for n in ast.walk(handler) if isinstance(n, ast.Return)]:
v = r.value
if isinstance(v, ast.Tuple) and len(v.elts) == 2:
code = v.elts[1]
if (isinstance(code, ast.Constant)
and isinstance(code.value, int)
and 500 <= code.value < 600):
return True
return False
def logs_a_traceback(handler):
"""An error/exception-level log call carrying exc_info."""
@@ -161,11 +210,12 @@ class TestHandlersCarryDetail:
offenders = []
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
seg = ast.get_source_segment(src, h) or ""
if generic not in seg:
if not returns_5xx(h):
continue
if enclosing_function(h) in PRE_EXISTING:
continue
missing = []
if not logs_a_traceback(h):
if not logs_a_traceback(h) and not only_catches_importerror(h):
missing.append("error-level log with exc_info")
if not returns_the_detail(h):
missing.append("describe_exception(e) in the response")
@@ -393,3 +393,358 @@ class TestTheManifestStaysRelocatable:
def test_it_matches_the_default_a_reader_falls_back_to(self, starlark_dir, tmp_path):
"""Stored and defaulted values must mean the same thing."""
assert self._install(tmp_path)['star_file'] == 'demo.star'
# ---------------------------------------------------------------------------
# The store loaded, then stopped loading, and nothing anywhere said why.
#
# #535 restored the routes, so the 404 was gone -- but two failure modes
# underneath it produce the same blank grid, and neither could be read from
# outside. On the device this was diagnosed on, /repository/browse answered
# 200 with 1000 apps in 27s while GitHub reported 18 of 60 unauthenticated
# requests remaining, with 48 installed plugins checking for updates against
# the same budget. When that budget runs out the store goes blank and says
# nothing at all.
# ---------------------------------------------------------------------------
def _repository_module():
"""Load tronbyte_repository.py the way the blueprint does."""
import importlib.util
import sys
from pathlib import Path
path = (Path(__file__).resolve().parents[2]
/ 'plugin-repos' / 'starlark-apps' / 'tronbyte_repository.py')
spec = importlib.util.spec_from_file_location('_tronbyte_repo_under_test', path)
module = importlib.util.module_from_spec(spec)
sys.modules['_tronbyte_repo_under_test'] = module
spec.loader.exec_module(module)
return module
class _Resp:
"""Enough of requests.Response for the paths under test."""
def __init__(self, status_code=200, payload=None, headers=None, raises=None):
self.status_code = status_code
self._payload = payload
self.headers = headers or {}
self._raises = raises
def json(self):
if self._raises is not None:
raise self._raises
return self._payload
class TestTheJsonGuardIsNotItselfACrash:
"""`except (json.JSONDecodeError, ValueError)` with no `import json`.
Evaluating that tuple raises NameError, so the guard written for exactly
this case never ran: a non-JSON body -- a captive portal, a proxy error
page, a DNS-hijacking router answering for api.github.com -- came out as
a 500 instead of the None the caller was written to handle.
"""
def test_a_non_json_body_returns_none(self):
repo = _repository_module().TronbyteRepository()
repo.session.get = lambda *a, **k: _Resp(
raises=ValueError("Expecting value: line 1 column 1 (char 0)"))
assert repo._make_request("https://api.github.com/anything") is None
def test_it_says_the_response_was_not_json(self):
repo = _repository_module().TronbyteRepository()
repo.session.get = lambda *a, **k: _Resp(raises=ValueError("nope"))
repo._make_request("https://api.github.com/anything")
assert 'JSON' in (repo.last_error or ''), repo.last_error
class TestAnExhaustedRateLimitSaysSo:
"""60 requests/hour unauthenticated, shared with every update check."""
def test_the_message_names_the_rate_limit(self):
repo = _repository_module().TronbyteRepository()
repo.session.get = lambda *a, **k: _Resp(
status_code=403,
headers={'X-RateLimit-Remaining': '0', 'X-RateLimit-Limit': '60'})
assert repo._make_request("https://api.github.com/anything") is None
assert 'rate limit' in (repo.last_error or '').lower(), repo.last_error
def test_it_mentions_being_unauthenticated_when_there_is_no_token(self):
repo = _repository_module().TronbyteRepository()
repo.session.get = lambda *a, **k: _Resp(
status_code=403,
headers={'X-RateLimit-Remaining': '0', 'X-RateLimit-Limit': '60'})
repo._make_request("https://api.github.com/anything")
assert 'unauthenticated' in (repo.last_error or ''), repo.last_error
def test_a_plain_403_is_not_reported_as_a_rate_limit(self):
repo = _repository_module().TronbyteRepository()
repo.session.get = lambda *a, **k: _Resp(
status_code=403, headers={'X-RateLimit-Remaining': '57'})
repo._make_request("https://api.github.com/anything")
assert 'rate limit' not in (repo.last_error or '').lower(), repo.last_error
class TestAFailedFetchIsNotAnEmptyRepository:
"""list_all_apps_cached turned every failure into an empty app list.
The route then reported that as a success, so a rate limit, a DNS failure
and a genuinely empty repository were all drawn as the same blank grid.
"""
def test_the_reason_comes_back_with_the_empty_list(self):
module = _repository_module()
repo = module.TronbyteRepository()
repo.list_apps = lambda: (False, None, "GitHub API rate limit exceeded")
result = repo.list_all_apps_cached()
assert result['count'] == 0
assert 'rate limit' in result['error'].lower(), result
def test_a_failure_is_not_cached_as_an_empty_repository(self):
module = _repository_module()
repo = module.TronbyteRepository()
repo.list_apps = lambda: (False, None, "boom")
repo.list_all_apps_cached()
assert module._apps_cache['data'] is None, \
"a failed fetch was cached, so the store stays empty for 2 hours"
def test_a_successful_fetch_reports_no_error(self):
module = _repository_module()
repo = module.TronbyteRepository()
repo.list_apps = lambda: (True, [{'id': 'a', 'path': 'apps/a'}], None)
repo._fetch_raw_file = lambda *a, **k: "name: A\nsummary: s\n"
assert repo.list_all_apps_cached().get('error') is None
class TestTheStoreReportsWhyItIsEmpty:
"""The route's half of the same failure."""
@pytest.fixture
def failing_repo(self):
repo = MagicMock()
repo.return_value.list_all_apps_cached.return_value = {
'apps': [], 'categories': [], 'authors': [], 'count': 0,
'cached': False,
'error': 'GitHub API rate limit exceeded (60 requests/hour, '
'unauthenticated).',
}
repo.return_value.get_rate_limit_info.return_value = {'remaining': 0}
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
return_value=repo):
yield repo
def test_browse_does_not_call_a_failure_a_success(self, client, failing_repo):
body = client.get('/api/v3/starlark/repository/browse').get_json()
assert body['status'] == 'error', body
def test_browse_answers_502_not_200(self, client, failing_repo):
resp = client.get('/api/v3/starlark/repository/browse')
assert resp.status_code == 502, resp.get_json()
def test_the_reason_reaches_the_page(self, client, failing_repo):
body = client.get('/api/v3/starlark/repository/browse').get_json()
assert 'rate limit' in body['message'].lower(), body
def test_categories_reports_it_too(self, client, failing_repo):
resp = client.get('/api/v3/starlark/repository/categories')
assert resp.status_code == 502
assert 'rate limit' in resp.get_json()['message'].lower()
@pytest.fixture
def working_repo(self):
repo = MagicMock()
repo.return_value.list_all_apps_cached.return_value = {
'apps': [{'id': 'quoteoftheday'}], 'categories': [], 'authors': [],
'count': 1, 'cached': False, 'error': None,
}
repo.return_value.get_rate_limit_info.return_value = {'remaining': 57}
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
return_value=repo):
yield repo
def test_a_working_fetch_is_still_a_success(self, client, working_repo):
resp = client.get('/api/v3/starlark/repository/browse')
assert resp.status_code == 200
assert resp.get_json()['status'] == 'success'
class TestACrashCarriesItsDetail:
"""Seventeen Starlark handlers answered 5xx with no detail at all."""
def test_browse_returns_the_exception_detail(self, client):
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
side_effect=ImportError("No module named 'yaml'")):
body = client.get('/api/v3/starlark/repository/browse').get_json()
assert 'yaml' in body.get('details', ''), body
def test_status_returns_the_exception_detail(self, client):
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
side_effect=RuntimeError("plugin manager is not attached")):
body = client.get('/api/v3/starlark/status').get_json()
assert 'plugin manager is not attached' in body.get('details', ''), body
class TestTheListingIsNotCappedAtOneThousand:
"""The contents API caps a directory at 1000 entries and does not say so.
tronbyt/apps returns exactly 1000 through that endpoint, which is the cap
rather than the app count -- the store looked complete while showing a
truncated repository.
"""
def _repo_with_tree(self, module, count):
repo = module.TronbyteRepository()
entries = [{'path': 'app%04d' % i, 'type': 'tree'} for i in range(count)]
def fake_request(url, timeout=10):
if url.endswith('/git/trees/main'):
return {'tree': [{'path': 'apps', 'type': 'tree', 'sha': 'deadbeef'}]}
if url.endswith('/git/trees/deadbeef'):
return {'tree': entries, 'truncated': False}
raise AssertionError("unexpected request: %s" % url)
repo._make_request = fake_request
return repo
def test_more_than_a_thousand_apps_are_listed(self):
module = _repository_module()
repo = self._repo_with_tree(module, 1400)
ok, apps, err = repo.list_apps()
assert ok, err
assert len(apps) == 1400
def test_the_path_is_still_the_one_manifest_fetches_use(self):
module = _repository_module()
repo = self._repo_with_tree(module, 3)
_, apps, _ = repo.list_apps()
assert apps[0]['path'] == 'apps/app0000', apps[0]
def test_dotfiles_and_files_are_skipped(self):
module = _repository_module()
repo = module.TronbyteRepository()
def fake_request(url, timeout=10):
if url.endswith('/git/trees/main'):
return {'tree': [{'path': 'apps', 'type': 'tree', 'sha': 'x'}]}
return {'tree': [{'path': '.github', 'type': 'tree'},
{'path': 'realapp', 'type': 'tree'},
{'path': 'README.md', 'type': 'blob'}]}
repo._make_request = fake_request
_, apps, _ = repo.list_apps()
assert [a['id'] for a in apps] == ['realapp']
def test_it_falls_back_to_the_contents_api(self):
"""A trees outage must not take the store down with it."""
module = _repository_module()
repo = module.TronbyteRepository()
def fake_request(url, timeout=10):
if '/git/trees/' in url:
repo.last_error = "GitHub API error 500"
return None
return [{'name': 'fallbackapp', 'path': 'apps/fallbackapp', 'type': 'dir'}]
repo._make_request = fake_request
ok, apps, err = repo.list_apps()
assert ok, err
assert [a['id'] for a in apps] == ['fallbackapp']
def test_both_paths_failing_reports_the_reason(self):
module = _repository_module()
repo = module.TronbyteRepository()
def fake_request(url, timeout=10):
repo.last_error = "Timed out reaching GitHub"
return None
repo._make_request = fake_request
ok, apps, err = repo.list_apps()
assert not ok
assert 'Timed out' in err, err
class TestTheStoreUsesTheTokenTheUserConfigured:
"""The store authenticated with a key nothing ever writes.
The three repository routes read `github_token` off config.json. Nothing
writes that key: config.template.json has no such field, no setting
offers it, and the token the user actually configures goes to
config_secrets.json as `github.api_token`, which PluginStoreManager loads
and every other GitHub caller uses.
So the store ran unauthenticated at 60 requests/hour on the same per-IP
budget as 48 plugins' update checks, while the configured token sat
unused raising that same budget to 5000. On the device this was found on,
/plugins/store/github-status reported `authenticated: true` with a
rate_limit of 5000 while /starlark/repository/browse reported a limit of
60 -- the store going blank was that 60 running out.
"""
def test_the_store_managers_token_is_used(self):
from web_interface.blueprints import api_v3 as mod
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token='ghp_configured')):
assert mod._starlark_github_token() == 'ghp_configured'
def test_a_hand_edited_config_key_still_works(self):
from web_interface.blueprints import api_v3 as mod
cfg = MagicMock()
cfg.load_config.return_value = {'github_token': 'ghp_by_hand'}
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token=None)), \
patch.object(mod.api_v3, 'config_manager', cfg):
assert mod._starlark_github_token() == 'ghp_by_hand'
def test_no_token_anywhere_is_not_an_error(self):
from web_interface.blueprints import api_v3 as mod
cfg = MagicMock()
cfg.load_config.return_value = {}
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token=None)), \
patch.object(mod.api_v3, 'config_manager', cfg):
assert mod._starlark_github_token() is None
def test_an_unreadable_config_does_not_take_the_store_down(self):
from web_interface.blueprints import api_v3 as mod
cfg = MagicMock()
cfg.load_config.side_effect = OSError("config.json is unreadable")
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token=None)), \
patch.object(mod.api_v3, 'config_manager', cfg):
assert mod._starlark_github_token() is None
def test_browse_hands_the_token_to_the_repository(self, client):
from web_interface.blueprints import api_v3 as mod
repo = MagicMock()
repo.return_value.list_all_apps_cached.return_value = {
'apps': [], 'categories': [], 'authors': [], 'count': 0,
'cached': False, 'error': None,
}
repo.return_value.get_rate_limit_info.return_value = {'remaining': 4999}
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token='ghp_configured')), \
patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
return_value=repo):
client.get('/api/v3/starlark/repository/browse')
repo.assert_called_once_with(github_token='ghp_configured')
+68 -29
View File
@@ -8858,7 +8858,7 @@ def get_starlark_status():
except Exception as e:
logger.exception("[Starlark] get_starlark_status failed")
return jsonify({'status': 'error', 'message': 'Failed to get Starlark status'}), 500
return jsonify({'status': 'error', 'message': 'Failed to get Starlark status', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/install-pixlet', methods=['POST'])
def install_pixlet():
@@ -8884,11 +8884,13 @@ def install_pixlet():
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 subprocess.TimeoutExpired as err:
logger.exception("[Starlark] Pixlet download timed out")
return jsonify({'status': 'error', 'message': 'Download timed out',
'details': describe_exception(err)}), 500
except Exception as e:
logger.exception("[Starlark] install_pixlet failed")
return jsonify({'status': 'error', 'message': 'Failed to install Pixlet'}), 500
return jsonify({'status': 'error', 'message': 'Failed to install Pixlet', 'details': describe_exception(e)}), 500
# The remaining eleven routes #330 dropped, restored the same way: apps CRUD,
@@ -8897,6 +8899,32 @@ def install_pixlet():
# these the store lists nothing and installing anything answers with the same
# generic 404 the Pixlet button did.
def _starlark_github_token() -> Optional[str]:
"""The GitHub token the Starlark store should authenticate with.
These routes used to read `github_token` off config.json, a key that is
written nowhere and offered by no setting -- so the store always ran
unauthenticated at 60 requests/hour, on the same per-IP budget every
plugin update check spends, while the token the user had actually
configured sat in config_secrets.json raising the same budget to 5000.
The store going blank was that budget running out.
Prefer the store manager's token, which is the one the settings UI
writes and validates; keep the config.json key as a fallback so a
hand-edited config still works.
"""
token = getattr(api_v3.plugin_store_manager, 'github_token', None)
if token:
return token
try:
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
return config.get('github_token')
except Exception:
logger.warning("[Starlark] Could not read config for a GitHub token", exc_info=True)
return None
def _get_tronbyte_repository_class() -> Type[Any]:
"""Import TronbyteRepository from plugin-repos directory."""
import importlib.util
@@ -9271,7 +9299,7 @@ def get_starlark_apps():
except Exception as e:
logger.exception("[Starlark] get_starlark_apps failed")
return jsonify({'status': 'error', 'message': 'Failed to get Starlark apps'}), 500
return jsonify({'status': 'error', 'message': 'Failed to get Starlark apps', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/apps/<app_id>', methods=['GET'])
def get_starlark_app(app_id):
@@ -9337,7 +9365,7 @@ def get_starlark_app(app_id):
except Exception as e:
logger.exception("[Starlark] get_starlark_app failed")
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app'}), 500
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/upload', methods=['POST'])
def upload_starlark_app():
@@ -9404,17 +9432,19 @@ def upload_starlark_app():
pass
except (OSError, IOError) as err:
# The detail goes to the log, not the response: it names absolute
# paths on the device, which the caller has no business seeing. The
# generic Exception arm below already did this; these two did not.
# This used to withhold the detail because it names absolute paths on
# the device. A full disk and a bad permission are indistinguishable
# without it, though, and describe_exception redacts credentials and
# truncates -- the same trade-off every other handler here makes.
logger.exception("[Starlark] File error uploading starlark app: %s", err)
return jsonify({'status': 'error', 'message': 'File error during upload'}), 500
return jsonify({'status': 'error', 'message': 'File error during upload',
'details': describe_exception(err)}), 500
except ImportError as err:
logger.exception("[Starlark] Module load error uploading starlark app: %s", err)
return jsonify({'status': 'error', 'message': 'Failed to load app module'}), 500
return jsonify({'status': 'error', 'message': 'Failed to load app module', 'details': describe_exception(err)}), 500
except Exception as err:
logger.exception("[Starlark] Unexpected error uploading starlark app: %s", err)
return jsonify({'status': 'error', 'message': 'Failed to upload app'}), 500
return jsonify({'status': 'error', 'message': 'Failed to upload app', 'details': describe_exception(err)}), 500
@api_v3.route('/starlark/apps/<app_id>', methods=['DELETE'])
def uninstall_starlark_app(app_id):
@@ -9445,7 +9475,7 @@ def uninstall_starlark_app(app_id):
except Exception as e:
logger.exception("[Starlark] uninstall_starlark_app failed")
return jsonify({'status': 'error', 'message': 'Failed to uninstall Starlark app'}), 500
return jsonify({'status': 'error', 'message': 'Failed to uninstall Starlark app', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/apps/<app_id>/config', methods=['GET'])
def get_starlark_app_config(app_id):
@@ -9492,7 +9522,7 @@ def get_starlark_app_config(app_id):
except Exception as e:
logger.exception("[Starlark] get_starlark_app_config failed")
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app config'}), 500
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app config', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/apps/<app_id>/config', methods=['PUT'])
def update_starlark_app_config(app_id):
@@ -9609,7 +9639,8 @@ def update_starlark_app_config(app_id):
except Exception as e:
logger.error(f"Failed to save config.json for {app_id}: {e}")
logger.exception("Failed to save Starlark configuration for %r", app_id)
return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500
return jsonify({'status': 'error', 'message': 'Failed to save configuration',
'details': describe_exception(e)}), 500
# Also update manifest for backward compatibility
app_data.setdefault('config', {}).update(data)
@@ -9621,7 +9652,7 @@ def update_starlark_app_config(app_id):
except Exception as e:
logger.exception("[Starlark] update_starlark_app_config failed")
return jsonify({'status': 'error', 'message': 'Failed to update Starlark app config'}), 500
return jsonify({'status': 'error', 'message': 'Failed to update Starlark app config', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/apps/<app_id>/toggle', methods=['POST'])
def toggle_starlark_app(app_id):
@@ -9661,7 +9692,7 @@ def toggle_starlark_app(app_id):
except Exception as e:
logger.exception("[Starlark] toggle_starlark_app failed")
return jsonify({'status': 'error', 'message': 'Failed to toggle Starlark app'}), 500
return jsonify({'status': 'error', 'message': 'Failed to toggle Starlark app', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/apps/<app_id>/render', methods=['POST'])
def render_starlark_app(app_id):
@@ -9690,7 +9721,7 @@ def render_starlark_app(app_id):
except Exception as e:
logger.exception("[Starlark] render_starlark_app failed")
return jsonify({'status': 'error', 'message': 'Failed to render Starlark app'}), 500
return jsonify({'status': 'error', 'message': 'Failed to render Starlark app', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/repository/browse', methods=['GET'])
def browse_tronbyte_repository():
@@ -9703,14 +9734,22 @@ def browse_tronbyte_repository():
try:
TronbyteRepository = _get_tronbyte_repository_class()
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
github_token = config.get('github_token')
repo = TronbyteRepository(github_token=github_token)
repo = TronbyteRepository(github_token=_starlark_github_token())
result = repo.list_all_apps_cached()
rate_limit = repo.get_rate_limit_info()
# An upstream failure used to arrive here as an empty app list and go
# out as 'success', so the store drew an empty grid and said nothing.
# 502: the request was fine, GitHub was not.
if result.get('error'):
return jsonify({
'status': 'error',
'message': result['error'],
'rate_limit': rate_limit,
}), 502
return jsonify({
'status': 'success',
'apps': result['apps'],
@@ -9723,7 +9762,7 @@ def browse_tronbyte_repository():
except Exception as e:
logger.exception("[Starlark] browse_tronbyte_repository failed")
return jsonify({'status': 'error', 'message': 'Failed to browse repository'}), 500
return jsonify({'status': 'error', 'message': 'Failed to browse repository', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/repository/install', methods=['POST'])
def install_from_tronbyte_repository():
@@ -9740,9 +9779,7 @@ def install_from_tronbyte_repository():
TronbyteRepository = _get_tronbyte_repository_class()
import tempfile
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
github_token = config.get('github_token')
repo = TronbyteRepository(github_token=github_token)
repo = TronbyteRepository(github_token=_starlark_github_token())
success, metadata, error = repo.get_app_metadata(data['app_id'])
if not success:
@@ -9811,23 +9848,25 @@ def install_from_tronbyte_repository():
except Exception as e:
logger.exception("[Starlark] install_from_tronbyte_repository failed")
return jsonify({'status': 'error', 'message': 'Failed to install from repository'}), 500
return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/repository/categories', methods=['GET'])
def get_tronbyte_categories():
"""Get list of available app categories (uses bulk cache)."""
try:
TronbyteRepository = _get_tronbyte_repository_class()
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
repo = TronbyteRepository(github_token=config.get('github_token'))
repo = TronbyteRepository(github_token=_starlark_github_token())
result = repo.list_all_apps_cached()
if result.get('error'):
return jsonify({'status': 'error', 'message': result['error']}), 502
return jsonify({'status': 'success', 'categories': result['categories']})
except Exception as e:
logger.exception("[Starlark] get_tronbyte_categories failed")
return jsonify({'status': 'error', 'message': 'Failed to fetch categories'}), 500
return jsonify({'status': 'error', 'message': 'Failed to fetch categories', 'details': describe_exception(e)}), 500
def _starlark_virtual_plugins() -> list:
@@ -0,0 +1,484 @@
/**
* ListFilter — shared search / filter / sort controller for the card-grid
* sections of the Plugin Manager.
*
* The Installed Plugins, Plugin Store and Starlark Apps sections all need the
* same machinery: debounced text search over a few fields, a handful of filter
* axes, a sort dropdown, an active-filter count with a Clear button, and a
* re-render. This owns that machinery; the caller keeps ownership of its own
* card markup via the `render` callback.
*
* Usage:
*
* const ctl = ListFilter.create({
* getItems: () => window.installedPlugins || [],
* render: (visible, total) => renderCards(visible, total),
* search: { el: 'my-search', fields: ['name', 'id', 'tags'] },
* sort: { el: 'my-sort', default: 'a-z', comparators: { 'a-z': fn } },
* controls: [{ type: 'pills', el: '#my-pills', attr: 'data-my-filter',
* key: 'filter', default: 'all', test: (item, v) => true }],
* clearEl: 'my-clear',
* });
* ctl.bind(); // idempotent — safe to call after every HTMX partial swap
* ctl.apply(); // filter + sort + render
*
* Optional `pagination` slices the result set and renders page controls; the
* `render` callback then receives just the current page. Optional `persist`
* takes read/write callbacks so the caller — not this helper — owns its
* storage keys.
*
* Element references are DOM ids, except `controls[].el` which is a CSS
* selector for the pill container.
*/
const ListFilter = (function () {
'use strict';
function debounce(fn, wait) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
function byId(id) {
return id ? document.getElementById(id) : null;
}
// Filter axes compare against their default to decide "is this axis active",
// so null/undefined/'' must not be conflated with a real selection.
function sameValue(a, b) {
if (a === b) return true;
if (a === null || a === undefined) return b === null || b === undefined;
return false;
}
// Build the lowercased search haystack. Array fields (e.g. tags) are
// flattened in, matching the existing store/starlark search behaviour.
//
// Values are read out of a Map rather than via item[field], which keeps
// static analysers from flagging a computed member access as an
// object-injection sink. Iteration follows `fields`, NOT the object's own
// key order: the fields are concatenated, so their order decides which
// values end up adjacent, and a multi-word query can span a field boundary.
function haystack(item, fields) {
if (!item) return '';
const values = new Map(Object.entries(item));
const parts = [];
(fields || []).forEach(field => {
const value = values.get(field);
if (Array.isArray(value)) {
value.forEach(v => { if (v) parts.push(String(v)); });
} else if (value) {
parts.push(String(value));
}
});
return parts.join(' ').toLowerCase();
}
function create(config) {
const cfg = config || {};
const searchCfg = cfg.search || null;
const sortCfg = cfg.sort || null;
const controls = Array.isArray(cfg.controls) ? cfg.controls : [];
const pageCfg = cfg.pagination || null;
const persistCfg = cfg.persist || null;
const idOf = typeof cfg.idOf === 'function' ? cfg.idOf : (item => item && item.id);
// Defaults double as the "inactive" value for each axis.
const defaults = {};
if (searchCfg) {
defaults.search = ''; // trimmed — what filtering and activeCount use
defaults.searchRaw = ''; // exactly what the user typed — what the input shows
}
if (sortCfg) defaults.sort = sortCfg.default !== undefined ? sortCfg.default : 'a-z';
controls.forEach(c => {
defaults[c.key] = c.default !== undefined ? c.default : null;
});
const state = Object.assign({}, defaults);
// page/perPage sit outside `defaults` on purpose: Clear Filters returns
// to page 1 but must NOT reset a per-page size the user chose.
if (pageCfg) {
state.page = 1;
state.perPage = pageCfg.defaultPerPage || 12;
}
// Seed persisted values. The caller supplies read()/write() so storage
// keys stay where they always were.
if (persistCfg && typeof persistCfg.read === 'function') {
const saved = persistCfg.read() || {};
if (sortCfg && saved.sort !== undefined && saved.sort !== null) state.sort = saved.sort;
if (pageCfg && saved.perPage) state.perPage = saved.perPage;
}
function persist() {
if (persistCfg && typeof persistCfg.write === 'function') persistCfg.write(state);
}
// Ids that stay visible even when they no longer match the active
// filters. Populated by the caller when the user acts on a card (e.g.
// toggling a plugin off while filtering by Enabled) so the card they
// just clicked doesn't vanish underneath the cursor. Cleared as soon as
// the user touches the toolbar.
const sticky = new Set();
function activeCount() {
let n = 0;
if (searchCfg && state.search) n++;
if (sortCfg && !sameValue(state.sort, defaults.sort)) n++;
controls.forEach(c => {
if (!sameValue(state[c.key], defaults[c.key])) n++;
});
return n;
}
function matches(item) {
if (searchCfg && state.search) {
if (!haystack(item, searchCfg.fields).includes(state.search.toLowerCase())) {
return false;
}
}
for (const c of controls) {
const value = state[c.key];
if (sameValue(value, defaults[c.key])) continue; // axis inactive
if (typeof c.test === 'function' && !c.test(item, value)) return false;
}
return true;
}
function compute() {
const all = (typeof cfg.getItems === 'function' ? cfg.getItems() : null) || [];
const total = all.length;
const list = all.filter(item => {
if (sticky.size > 0 && sticky.has(idOf(item))) return true;
return matches(item);
});
if (sortCfg && sortCfg.comparators) {
// An unrecognised sort key falls back to the default comparator,
// matching the switch-with-default the store code used.
const cmp = sortCfg.comparators[state.sort] || sortCfg.comparators[defaults.sort];
if (typeof cmp === 'function') list.sort(cmp);
}
return { list: list, total: total };
}
// Reflect current state back onto the controls, so programmatic changes
// and a fresh partial swap both land on a correctly-lit toolbar.
function syncControls() {
if (searchCfg) {
// The toolbar markup is rebuilt on every HTMX partial swap while
// this controller (and its state) survives — put the text back.
const el = byId(searchCfg.el);
const text = state.searchRaw !== undefined ? state.searchRaw : state.search;
if (el && el.value !== text) el.value = text;
}
if (sortCfg) {
const el = byId(sortCfg.el);
if (el && el.value !== state.sort) el.value = state.sort;
}
if (pageCfg && pageCfg.perPageEl) {
const el = byId(pageCfg.perPageEl);
if (el && el.value !== String(state.perPage)) el.value = String(state.perPage);
}
controls.forEach(c => {
const value = state[c.key];
if (c.type === 'pills') {
const container = document.querySelector(c.el);
if (!container) return;
container.querySelectorAll('[' + c.attr + ']').forEach(btn => {
const on = btn.getAttribute(c.attr) === String(value);
btn.setAttribute('data-active', on ? 'true' : 'false');
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
});
} else if (c.type === 'select') {
const el = byId(c.el);
if (el && el.value !== (value === null ? '' : value)) {
el.value = value === null ? '' : value;
}
} else if (c.type === 'cycle') {
const btn = byId(c.el);
// The caller renders cycle buttons so each section keeps its
// own label/icon/class treatment.
if (btn && typeof c.render === 'function') c.render(btn, value);
}
});
}
function updateChrome(list, total) {
const n = activeCount();
const countEl = byId(cfg.countEl);
if (countEl && typeof cfg.countFormat === 'function') {
countEl.textContent = cfg.countFormat(list.length, total, n > 0);
}
const activeEl = byId(cfg.activeCountEl);
if (activeEl) {
activeEl.classList.toggle('hidden', n === 0);
activeEl.textContent = n + ' filter' + (n !== 1 ? 's' : '') + ' active';
}
const clearEl = byId(cfg.clearEl);
if (clearEl) clearEl.classList.toggle('hidden', n === 0);
if (searchCfg && searchCfg.clearEl) {
const searchClear = byId(searchCfg.clearEl);
if (searchClear) searchClear.classList.toggle('hidden', !state.search);
}
syncControls();
if (typeof cfg.onChrome === 'function') cfg.onChrome(state, list, total);
}
// Page-number strip with leading/trailing ellipsis, producing the same
// controls the plugin store has always rendered.
//
// Built with createElement rather than by concatenating an HTML string.
// Nothing interpolated here is user-controlled — only page integers and
// these class constants — but assembling markup into innerHTML is the
// pattern static analysers flag as an XSS sink, and building nodes is no
// less clear. It also lets each button own its listener directly instead
// of re-querying the container afterwards.
const PAGE_BTN_CLASS = 'px-3 py-1 text-sm rounded-md border transition-colors';
const PAGE_ACTIVE_CLASS = 'bg-blue-600 text-white border-blue-600';
const PAGE_NORMAL_CLASS = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer';
const PAGE_DISABLED_CLASS = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed';
function renderPagination(containerId, totalPages, currentPage) {
const container = byId(containerId);
if (!container) return;
// textContent = '' drops the previous strip without parsing markup.
container.textContent = '';
if (totalPages <= 1) return;
const goTo = target => {
if (target >= 1 && target <= totalPages && target !== currentPage) {
state.page = target;
// Page moves re-slice only; filters and sort are unchanged.
apply(true);
const grid = byId(pageCfg && pageCfg.scrollToEl);
if (grid && typeof grid.scrollIntoView === 'function') {
grid.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
};
const addPageButton = (label, target, variant) => {
const btn = document.createElement('button');
btn.className = PAGE_BTN_CLASS + ' ' + (
variant === 'active' ? PAGE_ACTIVE_CLASS
: variant === 'disabled' ? PAGE_DISABLED_CLASS
: PAGE_NORMAL_CLASS);
btn.setAttribute('data-list-page', String(target));
if (variant === 'disabled') btn.disabled = true;
btn.textContent = label;
btn.addEventListener('click', () => goTo(target));
container.appendChild(btn);
};
addPageButton('\u00ab', currentPage - 1, currentPage <= 1 ? 'disabled' : 'normal');
const pages = [];
pages.push(1);
if (currentPage > 3) pages.push('...');
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) pages.push('...');
if (totalPages > 1) pages.push(totalPages);
pages.forEach(entry => {
if (entry === '...') {
const gap = document.createElement('span');
gap.className = 'px-2 py-1 text-sm text-gray-400';
gap.textContent = '\u2026';
container.appendChild(gap);
} else {
addPageButton(String(entry), entry, entry === currentPage ? 'active' : 'normal');
}
});
addPageButton('\u00bb', currentPage + 1, currentPage >= totalPages ? 'disabled' : 'normal');
}
function apply(skipPageReset) {
const result = compute();
if (!pageCfg) {
updateChrome(result.list, result.total);
if (typeof cfg.render === 'function') cfg.render(result.list, result.total);
return result;
}
if (!skipPageReset) state.page = 1;
const total = result.list.length;
const totalPages = Math.max(1, Math.ceil(total / state.perPage));
if (state.page > totalPages) state.page = totalPages;
const start = (state.page - 1) * state.perPage;
const end = Math.min(start + state.perPage, total);
const pageItems = result.list.slice(start, end);
const info = total > 0
? (typeof pageCfg.infoFormat === 'function'
? pageCfg.infoFormat(start + 1, end, total)
: `Showing ${start + 1}\u2013${end} of ${total}`)
: (pageCfg.emptyText || 'No results match your filters');
[pageCfg.infoEl, pageCfg.infoBottomEl].forEach(id => {
const el = byId(id);
if (el) el.textContent = info;
});
renderPagination(pageCfg.topEl, totalPages, state.page);
renderPagination(pageCfg.bottomEl, totalPages, state.page);
updateChrome(result.list, result.total);
if (typeof cfg.render === 'function') cfg.render(pageItems, result.total);
return result;
}
function setSearch(value) {
// Keep the raw text so syncControls can put it back verbatim. Writing
// the trimmed value into the input would eat a trailing space (and
// reset the caret) mid-word, which makes multi-word terms untypable.
state.searchRaw = value || '';
state.search = state.searchRaw.trim();
sticky.clear();
apply();
}
function reset() {
// Only the filter axes reset; a chosen page size is a preference,
// not a filter, so it survives Clear Filters.
Object.assign(state, defaults);
if (pageCfg) state.page = 1;
sticky.clear();
if (searchCfg) {
const el = byId(searchCfg.el);
if (el) el.value = '';
}
persist();
syncControls();
apply();
}
function bind() {
if (searchCfg) {
const input = byId(searchCfg.el);
if (input && !input._listFilterInit) {
input._listFilterInit = true;
const run = debounce(() => setSearch(input.value), searchCfg.debounceMs || 300);
input.addEventListener('input', run);
input.addEventListener('keydown', e => {
if (e.key === 'Escape') {
input.value = '';
setSearch('');
}
});
}
const searchClear = searchCfg.clearEl ? byId(searchCfg.clearEl) : null;
if (searchClear && !searchClear._listFilterInit) {
searchClear._listFilterInit = true;
searchClear.addEventListener('click', () => {
const el = byId(searchCfg.el);
if (el) el.value = '';
setSearch('');
});
}
}
if (sortCfg) {
const el = byId(sortCfg.el);
if (el && !el._listFilterInit) {
el._listFilterInit = true;
el.addEventListener('change', function () {
state.sort = this.value;
sticky.clear();
persist();
apply();
});
}
}
controls.forEach(c => {
if (c.type === 'pills') {
const container = document.querySelector(c.el);
if (!container || container._listFilterInit) return;
container._listFilterInit = true;
// Delegated, so the pills survive any markup re-render.
container.addEventListener('click', event => {
const btn = event.target.closest('[' + c.attr + ']');
if (!btn || !container.contains(btn)) return;
state[c.key] = btn.getAttribute(c.attr);
sticky.clear();
apply();
});
} else if (c.type === 'select') {
const el = byId(c.el);
if (!el || el._listFilterInit) return;
el._listFilterInit = true;
el.addEventListener('change', function () {
state[c.key] = this.value;
sticky.clear();
apply();
});
} else if (c.type === 'cycle') {
const btn = byId(c.el);
if (!btn || btn._listFilterInit) return;
btn._listFilterInit = true;
const values = Array.isArray(c.values) ? c.values : [null];
btn.addEventListener('click', () => {
const at = values.findIndex(v => sameValue(v, state[c.key]));
state[c.key] = values[(at + 1) % values.length];
sticky.clear();
apply();
});
}
});
if (pageCfg && pageCfg.perPageEl) {
const el = byId(pageCfg.perPageEl);
if (el && !el._listFilterInit) {
el._listFilterInit = true;
el.addEventListener('change', function () {
state.perPage = parseInt(this.value) || (pageCfg.defaultPerPage || 12);
persist();
apply();
});
}
}
const clearEl = byId(cfg.clearEl);
if (clearEl && !clearEl._listFilterInit) {
clearEl._listFilterInit = true;
clearEl.addEventListener('click', reset);
}
}
return {
state: state,
sticky: sticky,
activeCount: activeCount,
bind: bind,
apply: apply,
reset: reset,
setSearch: setSearch,
syncControls: syncControls,
};
}
return { create: create };
})();
// Export
if (typeof module !== 'undefined' && module.exports) {
module.exports = ListFilter;
} else {
window.ListFilter = ListFilter;
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -974,7 +974,9 @@
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/config_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
<!-- Shared search/filter/sort controller for the plugin card grids -->
<script src="{{ url_for('static', filename='v3/js/plugins/list_filter.js') }}" defer></script>
<!-- Load config utilities -->
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
@@ -29,6 +29,58 @@
<span id="installed-count" class="text-sm text-gray-500 font-medium">0 installed</span>
</div>
</div>
<!-- Search / Filter / Sort Bar -->
<div id="installed-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Search -->
<div class="relative flex-1 min-w-[12rem]">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<input type="text"
id="installed-search"
class="form-control text-sm pl-9 pr-8 py-2"
placeholder="Search installed plugins&hellip;"
autocomplete="off"
aria-label="Search installed plugins">
<button type="button" id="installed-search-clear"
class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Clear search">
<i class="fas fa-times-circle"></i>
</button>
</div>
<!-- Status filter pills -->
<div id="installed-filter-pills" class="flex items-center gap-1.5" role="group" aria-label="Filter installed plugins by status">
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
data-installed-filter="all" data-active="true" aria-pressed="true">All</button>
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
data-installed-filter="enabled" data-active="false" aria-pressed="false">Enabled</button>
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
data-installed-filter="disabled" data-active="false" aria-pressed="false">Disabled</button>
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
data-installed-filter="updates" data-active="false" aria-pressed="false"
title="Show only plugins with a newer version available">
Updates<span id="installed-updates-count" class="badge badge-info ml-1.5 hidden">0</span>
</button>
</div>
<!-- Sort -->
<select id="installed-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white" aria-label="Sort installed plugins">
<option value="a-z">A &rarr; Z</option>
<option value="z-a">Z &rarr; A</option>
<option value="status">Updates first</option>
<option value="recent">Recently updated</option>
<option value="category">Category</option>
</select>
<div class="flex-1"></div>
<!-- Clear -->
<button type="button" id="installed-clear-filters"
class="hidden text-sm px-3 py-1.5 rounded-md border border-red-300 bg-white text-red-600 hover:bg-red-50 transition-colors">
<i class="fas fa-times mr-1"></i>Clear
</button>
</div>
<div id="installed-plugins-content" class="block">
<div id="installed-plugins-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
<!-- Skeleton cards shown while installed plugins load -->