mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-22 22: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 runtime storage (installed .star files and cached renders)
|
||||||
/starlark-apps/
|
/starlark-apps/
|
||||||
skin_renders/
|
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.
|
Fetches app listings, metadata, and downloads .star files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
@@ -49,6 +50,13 @@ class TronbyteRepository:
|
|||||||
self.base_url = "https://api.github.com"
|
self.base_url = "https://api.github.com"
|
||||||
self.raw_url = "https://raw.githubusercontent.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()
|
self.session = requests.Session()
|
||||||
if github_token:
|
if github_token:
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
@@ -70,29 +78,50 @@ class TronbyteRepository:
|
|||||||
Returns:
|
Returns:
|
||||||
JSON response or None on error
|
JSON response or None on error
|
||||||
"""
|
"""
|
||||||
|
self.last_error = None
|
||||||
try:
|
try:
|
||||||
response = self.session.get(url, timeout=timeout)
|
response = self.session.get(url, timeout=timeout)
|
||||||
|
|
||||||
if response.status_code == 403:
|
if response.status_code in (403, 429):
|
||||||
# Rate limit exceeded
|
# 403 is both "rate limited" and "forbidden"; the remaining
|
||||||
logger.warning("[Tronbyte Repo] GitHub API rate limit exceeded")
|
# 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
|
return None
|
||||||
elif response.status_code == 404:
|
elif response.status_code == 404:
|
||||||
|
self.last_error = "Not found on GitHub"
|
||||||
logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
|
logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
|
||||||
return None
|
return None
|
||||||
elif response.status_code != 200:
|
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}")
|
logger.error(f"[Tronbyte Repo] GitHub API error: {response.status_code}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
|
self.last_error = "Timed out reaching GitHub"
|
||||||
logger.error(f"[Tronbyte Repo] Request timeout: {url}")
|
logger.error(f"[Tronbyte Repo] Request timeout: {url}")
|
||||||
return None
|
return None
|
||||||
except requests.RequestException as e:
|
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)
|
logger.error(f"[Tronbyte Repo] Request error: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
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)
|
logger.error(f"[Tronbyte Repo] JSON parse error for {url}: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -125,6 +154,61 @@ class TronbyteRepository:
|
|||||||
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
|
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
|
||||||
return None
|
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]]:
|
def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
List all available apps in the repository.
|
List all available apps in the repository.
|
||||||
@@ -132,26 +216,17 @@ class TronbyteRepository:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, apps_list, error_message)
|
Tuple of (success, apps_list, error_message)
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
|
apps = self._list_app_dirs_via_trees()
|
||||||
|
if apps is None:
|
||||||
data = self._make_request(url)
|
# Fall back rather than fail: the contents API was what shipped,
|
||||||
if data is None:
|
# so a trees-only outage should not take the store down with it.
|
||||||
return False, None, "Failed to fetch repository contents"
|
trees_error = self.last_error
|
||||||
|
logger.warning(
|
||||||
if not isinstance(data, list):
|
f"[Tronbyte Repo] Trees listing failed ({trees_error}); "
|
||||||
return False, None, "Invalid response format"
|
"falling back to the contents API")
|
||||||
|
apps = self._list_app_dirs_via_contents()
|
||||||
# Filter directories (apps)
|
if apps is None:
|
||||||
apps = []
|
return False, None, self.last_error or trees_error or "Failed to fetch repository contents"
|
||||||
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')
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"Found {len(apps)} apps in repository")
|
logger.info(f"Found {len(apps)} apps in repository")
|
||||||
return True, apps, None
|
return True, apps, None
|
||||||
@@ -267,14 +342,22 @@ class TronbyteRepository:
|
|||||||
'categories': _apps_cache['categories'],
|
'categories': _apps_cache['categories'],
|
||||||
'authors': _apps_cache['authors'],
|
'authors': _apps_cache['authors'],
|
||||||
'count': len(_apps_cache['data']),
|
'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()
|
success, app_dirs, error = self.list_apps()
|
||||||
if not success or not app_dirs:
|
if not success or not app_dirs:
|
||||||
logger.error(f"Failed to list apps for bulk fetch: {error}")
|
# Returning an empty list here used to read downstream as "the
|
||||||
return {'apps': [], 'categories': [], 'authors': [], 'count': 0, 'cached': False}
|
# 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...")
|
logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...")
|
||||||
|
|
||||||
@@ -341,7 +424,8 @@ class TronbyteRepository:
|
|||||||
'categories': categories,
|
'categories': categories,
|
||||||
'authors': authors,
|
'authors': authors,
|
||||||
'count': len(apps_with_metadata),
|
'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]]:
|
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()
|
src = open("web_interface/blueprints/api_v3.py").read()
|
||||||
tree = ast.parse(src)
|
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):
|
def logs_a_traceback(handler):
|
||||||
"""An error/exception-level log call carrying exc_info."""
|
"""An error/exception-level log call carrying exc_info."""
|
||||||
@@ -161,11 +210,12 @@ class TestHandlersCarryDetail:
|
|||||||
|
|
||||||
offenders = []
|
offenders = []
|
||||||
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
|
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
|
||||||
seg = ast.get_source_segment(src, h) or ""
|
if not returns_5xx(h):
|
||||||
if generic not in seg:
|
continue
|
||||||
|
if enclosing_function(h) in PRE_EXISTING:
|
||||||
continue
|
continue
|
||||||
missing = []
|
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")
|
missing.append("error-level log with exc_info")
|
||||||
if not returns_the_detail(h):
|
if not returns_the_detail(h):
|
||||||
missing.append("describe_exception(e) in the response")
|
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):
|
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."""
|
"""Stored and defaulted values must mean the same thing."""
|
||||||
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
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:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] get_starlark_status failed")
|
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'])
|
@api_v3.route('/starlark/install-pixlet', methods=['POST'])
|
||||||
def install_pixlet():
|
def install_pixlet():
|
||||||
@@ -8884,11 +8884,13 @@ def install_pixlet():
|
|||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': f'Failed to download Pixlet: {result.stderr}'}), 500
|
return jsonify({'status': 'error', 'message': f'Failed to download Pixlet: {result.stderr}'}), 500
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired as err:
|
||||||
return jsonify({'status': 'error', 'message': 'Download timed out'}), 500
|
logger.exception("[Starlark] Pixlet download timed out")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Download timed out',
|
||||||
|
'details': describe_exception(err)}), 500
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] install_pixlet failed")
|
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,
|
# 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
|
# these the store lists nothing and installing anything answers with the same
|
||||||
# generic 404 the Pixlet button did.
|
# 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]:
|
def _get_tronbyte_repository_class() -> Type[Any]:
|
||||||
"""Import TronbyteRepository from plugin-repos directory."""
|
"""Import TronbyteRepository from plugin-repos directory."""
|
||||||
import importlib.util
|
import importlib.util
|
||||||
@@ -9271,7 +9299,7 @@ def get_starlark_apps():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] get_starlark_apps failed")
|
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'])
|
@api_v3.route('/starlark/apps/<app_id>', methods=['GET'])
|
||||||
def get_starlark_app(app_id):
|
def get_starlark_app(app_id):
|
||||||
@@ -9337,7 +9365,7 @@ def get_starlark_app(app_id):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] get_starlark_app failed")
|
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'])
|
@api_v3.route('/starlark/upload', methods=['POST'])
|
||||||
def upload_starlark_app():
|
def upload_starlark_app():
|
||||||
@@ -9404,17 +9432,19 @@ def upload_starlark_app():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
except (OSError, IOError) as err:
|
except (OSError, IOError) as err:
|
||||||
# The detail goes to the log, not the response: it names absolute
|
# This used to withhold the detail because it names absolute paths on
|
||||||
# paths on the device, which the caller has no business seeing. The
|
# the device. A full disk and a bad permission are indistinguishable
|
||||||
# generic Exception arm below already did this; these two did not.
|
# 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)
|
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:
|
except ImportError as err:
|
||||||
logger.exception("[Starlark] Module load error uploading starlark app: %s", 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:
|
except Exception as err:
|
||||||
logger.exception("[Starlark] Unexpected error uploading starlark app: %s", 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'])
|
@api_v3.route('/starlark/apps/<app_id>', methods=['DELETE'])
|
||||||
def uninstall_starlark_app(app_id):
|
def uninstall_starlark_app(app_id):
|
||||||
@@ -9445,7 +9475,7 @@ def uninstall_starlark_app(app_id):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] uninstall_starlark_app failed")
|
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'])
|
@api_v3.route('/starlark/apps/<app_id>/config', methods=['GET'])
|
||||||
def get_starlark_app_config(app_id):
|
def get_starlark_app_config(app_id):
|
||||||
@@ -9492,7 +9522,7 @@ def get_starlark_app_config(app_id):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] get_starlark_app_config failed")
|
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'])
|
@api_v3.route('/starlark/apps/<app_id>/config', methods=['PUT'])
|
||||||
def update_starlark_app_config(app_id):
|
def update_starlark_app_config(app_id):
|
||||||
@@ -9609,7 +9639,8 @@ def update_starlark_app_config(app_id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save config.json for {app_id}: {e}")
|
logger.error(f"Failed to save config.json for {app_id}: {e}")
|
||||||
logger.exception("Failed to save Starlark configuration for %r", app_id)
|
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
|
# Also update manifest for backward compatibility
|
||||||
app_data.setdefault('config', {}).update(data)
|
app_data.setdefault('config', {}).update(data)
|
||||||
@@ -9621,7 +9652,7 @@ def update_starlark_app_config(app_id):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] update_starlark_app_config failed")
|
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'])
|
@api_v3.route('/starlark/apps/<app_id>/toggle', methods=['POST'])
|
||||||
def toggle_starlark_app(app_id):
|
def toggle_starlark_app(app_id):
|
||||||
@@ -9661,7 +9692,7 @@ def toggle_starlark_app(app_id):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] toggle_starlark_app failed")
|
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'])
|
@api_v3.route('/starlark/apps/<app_id>/render', methods=['POST'])
|
||||||
def render_starlark_app(app_id):
|
def render_starlark_app(app_id):
|
||||||
@@ -9690,7 +9721,7 @@ def render_starlark_app(app_id):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] render_starlark_app failed")
|
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'])
|
@api_v3.route('/starlark/repository/browse', methods=['GET'])
|
||||||
def browse_tronbyte_repository():
|
def browse_tronbyte_repository():
|
||||||
@@ -9703,14 +9734,22 @@ def browse_tronbyte_repository():
|
|||||||
try:
|
try:
|
||||||
TronbyteRepository = _get_tronbyte_repository_class()
|
TronbyteRepository = _get_tronbyte_repository_class()
|
||||||
|
|
||||||
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||||
github_token = config.get('github_token')
|
|
||||||
repo = TronbyteRepository(github_token=github_token)
|
|
||||||
|
|
||||||
result = repo.list_all_apps_cached()
|
result = repo.list_all_apps_cached()
|
||||||
|
|
||||||
rate_limit = repo.get_rate_limit_info()
|
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({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'apps': result['apps'],
|
'apps': result['apps'],
|
||||||
@@ -9723,7 +9762,7 @@ def browse_tronbyte_repository():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] browse_tronbyte_repository failed")
|
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'])
|
@api_v3.route('/starlark/repository/install', methods=['POST'])
|
||||||
def install_from_tronbyte_repository():
|
def install_from_tronbyte_repository():
|
||||||
@@ -9740,9 +9779,7 @@ def install_from_tronbyte_repository():
|
|||||||
TronbyteRepository = _get_tronbyte_repository_class()
|
TronbyteRepository = _get_tronbyte_repository_class()
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||||
github_token = config.get('github_token')
|
|
||||||
repo = TronbyteRepository(github_token=github_token)
|
|
||||||
|
|
||||||
success, metadata, error = repo.get_app_metadata(data['app_id'])
|
success, metadata, error = repo.get_app_metadata(data['app_id'])
|
||||||
if not success:
|
if not success:
|
||||||
@@ -9811,23 +9848,25 @@ def install_from_tronbyte_repository():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] install_from_tronbyte_repository failed")
|
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'])
|
@api_v3.route('/starlark/repository/categories', methods=['GET'])
|
||||||
def get_tronbyte_categories():
|
def get_tronbyte_categories():
|
||||||
"""Get list of available app categories (uses bulk cache)."""
|
"""Get list of available app categories (uses bulk cache)."""
|
||||||
try:
|
try:
|
||||||
TronbyteRepository = _get_tronbyte_repository_class()
|
TronbyteRepository = _get_tronbyte_repository_class()
|
||||||
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||||
repo = TronbyteRepository(github_token=config.get('github_token'))
|
|
||||||
|
|
||||||
result = repo.list_all_apps_cached()
|
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']})
|
return jsonify({'status': 'success', 'categories': result['categories']})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] get_tronbyte_categories failed")
|
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:
|
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/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/config_manager.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/plugins/install_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 -->
|
<!-- Load config utilities -->
|
||||||
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
|
<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>
|
<span id="installed-count" class="text-sm text-gray-500 font-medium">0 installed</span>
|
||||||
</div>
|
</div>
|
||||||
</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-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">
|
<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 -->
|
<!-- Skeleton cards shown while installed plugins load -->
|
||||||
|
|||||||
Reference in New Issue
Block a user