mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-17 20:39:13 +00:00
Compare commits
2
Commits
968b953a51
...
4423ec33d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4423ec33d5 | ||
|
|
26769ee37f |
@@ -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
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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.
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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', /…|…/.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); });
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+57
@@ -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');
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
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', /</.test(evil));
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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…"
|
||||
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 → Z</option>
|
||||
<option value="z-a">Z → 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 -->
|
||||
|
||||
Reference in New Issue
Block a user