feat(dashboard): nous-blue theme, bulk sessions, schedule picker (#37383)
* feat(dashboard): nous-blue theme, bulk sessions, schedule picker
Batch of related dashboard improvements gathered on
austin/fix/dashboard-changes:
* Nous Blue theme — faithful port of the LENS_5I overlay system onto
the existing DashboardTheme. Lifts the foreground inversion layer to
z-index 200 to fix the long-standing hover / loading visual artifact,
adds an explicit swatchColors slot so the theme picker shows the
post-inversion preview, and migrates the legacy "lens-5i" theme key
from localStorage / API to "nous-blue" on first read.
* Theme-aware series colors: new --series-input-token /
--series-output-token CSS vars consumed by Analytics + Models
charts; ToolCall + ModelInfoCard switched to semantic
--color-success for diff lines and the Tools capability badge.
* Analytics + Models headers: consolidate period selector + refresh
next to the page title and drop the redundant period badge.
* Bulk session management — "Delete empty (N)" button + per-row
checkboxes with shift-click range select and a bulk-delete action
bar. Backed by SessionDB.delete_sessions() /
delete_empty_sessions() plus POST /api/sessions/bulk-delete and
DELETE /api/sessions/empty (registered before the templated
/api/sessions/{session_id} family so they don't get shadowed).
Hard cap of 500 IDs per bulk request. Full pytest coverage.
* Cron page — human-readable schedule picker (every-interval / daily
/ weekly / monthly / once / custom) replaces the raw cron
expression input; the job list now renders "Weekly on Mon, Wed,
Fri at 14:30" instead of "30 14 * * 1,3,5". English-only ordinals
for monthly schedules so non-English locales don't get incorrect
suffixes.
* example-dashboard plugin moved from plugins/ to tests/fixtures/ so
stock installs no longer ship the demo. Tests install it
dynamically via a pytest fixture that also reorders the FastAPI
routes.
* i18n: 40+ new keys for the bulk-select UI and schedule
picker/describer translated across all 16 locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(dashboard): dedupe memory provider picker
The memory provider <Select> lived on both /system and /plugins,
writing the same config.yaml field through two different endpoints
with no cross-page refresh. Remove the picker from /system in favor
of a read-only status row + link to /plugins, where it pairs with
the context-engine picker under "Plugin providers".
/system retains the destructive admin controls (file sizes, Reset
MEMORY.md / USER.md / all). The api.setMemoryProvider client and
PUT /api/memory/provider backend endpoint are left in place for
CLI / script callers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(dashboard): address Copilot review on PR #37383
- Backdrop layer-stack comment claimed LENS_5I-style themes override
--component-backdrop-bg-blend-mode to multiply, but our only
LENS_5I-style theme (nous-blue) keeps the default difference.
Reword to describe what the code actually does and present the
var as a forward-looking extension hook.
- /api/sessions/bulk-delete docstring promised the response would
echo back the list of deleted IDs, but the implementation only
returns {ok, deleted}. Tighten the docstring to match the wire
format; the client already knows what it asked to delete, so the
IDs aren't needed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(dashboard): address copilot review on cron describe + bulk-select checkbox
- schedule.ts: restrict `describeCronExpression` to strictly 5-field cron
expressions. The backend `parse_schedule` also accepts the 6-field
`min hour dom month dow year` form, and humanising those by
destructuring only the first five fields would silently drop the year
(e.g. ``0 9 * * * 2099`` rendered as "Daily at 09:00"). 6+ field
expressions now fall through to the raw-string fallback so the user
sees what's actually scheduled.
- SessionsPage.tsx (SessionRow): wire the bulk-select Checkbox's
``onClick`` directly instead of attaching it to a parent ``<span>``
with a no-op ``onCheckedChange``. Radix forwards onClick to the
underlying ``<button role=checkbox>``, so the same handler now drives
both mouse clicks (preserving shift-key state for range select) and
keyboard activation (Space on the focused checkbox, which the browser
synthesises as a click on the <button>). Improves a11y / keyboard UX
without changing the controlled-selection model.
- SessionsPage.tsx: also extend ``SessionRowProps`` with the new
``onRename`` / ``onExport`` props introduced on main so the row's
destructured prop types resolve after the merge.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
@@ -14,6 +15,97 @@ from hermes_cli.config import (
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Path to the test-only example-dashboard plugin. Lives under
|
||||
# tests/fixtures/ so the bundled-plugins directory stays clean — stock
|
||||
# installs no longer ship a dummy "Example" sidebar tab. Tests that
|
||||
# depend on its routes opt in via the `_install_example_plugin` fixture
|
||||
# below.
|
||||
_EXAMPLE_PLUGIN_FIXTURE = (
|
||||
Path(__file__).resolve().parent.parent / "fixtures" / "plugins" / "example-dashboard"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _install_example_plugin(_isolate_hermes_home):
|
||||
"""Drop the example-dashboard fixture into the per-test HERMES_HOME
|
||||
user-plugins directory and force the web_server's dashboard plugin
|
||||
cache + API mount to rediscover it.
|
||||
|
||||
The plugin used to live under ``<repo>/plugins/example-dashboard/``
|
||||
and was loaded for every install, putting an "Example" tab in every
|
||||
user's sidebar. It is now a tests-only fixture: any test that needs
|
||||
``/api/plugins/example/hello`` or ``/dashboard-plugins/example/...``
|
||||
requests this fixture so the plugin appears only for that test's
|
||||
isolated ``HERMES_HOME``.
|
||||
|
||||
The user-plugin source is preferred over a transient
|
||||
``HERMES_BUNDLED_PLUGINS`` override because the bundled dir is
|
||||
resolved per-call (other tests in the suite implicitly rely on the
|
||||
real bundled plugins — kanban, hermes-achievements, model providers
|
||||
— being available, and globally swapping that root would yank them
|
||||
all). User plugins are first in the discovery search order, so
|
||||
laying down the fixture here is enough.
|
||||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli import web_server
|
||||
|
||||
user_plugins_dir = get_hermes_home() / "plugins"
|
||||
user_plugins_dir.mkdir(parents=True, exist_ok=True)
|
||||
dst = user_plugins_dir / "example-dashboard"
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(_EXAMPLE_PLUGIN_FIXTURE, dst)
|
||||
|
||||
# Snapshot the existing routes BEFORE mounting so we can:
|
||||
# 1. Identify the routes the mount call appends.
|
||||
# 2. Restore the original list on teardown — otherwise leftover
|
||||
# ``/api/plugins/example/*`` routes leak into subsequent tests
|
||||
# and start serving requests against a torn-down HERMES_HOME.
|
||||
app = web_server.app
|
||||
original_routes = list(app.router.routes)
|
||||
|
||||
# Bust the module-level cache and re-discover so the example plugin
|
||||
# shows up in `_get_dashboard_plugins()`. `_mount_plugin_api_routes`
|
||||
# imports the plugin's `plugin_api.py` and ``include_router``s its
|
||||
# FastAPI router under ``/api/plugins/example/*``. The static-asset
|
||||
# route at ``/dashboard-plugins/<name>/<path>`` reads the plugins
|
||||
# list dynamically per request, so the rescan alone is enough for
|
||||
# the static-asset tests; the API auth tests additionally need the
|
||||
# route reorder below.
|
||||
web_server._dashboard_plugins_cache = None
|
||||
web_server._get_dashboard_plugins(force_rescan=True)
|
||||
web_server._mount_plugin_api_routes()
|
||||
|
||||
# ``include_router`` appends the new routes to the END of
|
||||
# ``app.router.routes``. That works fine at import time — the SPA
|
||||
# catch-all ``mount_spa(app)`` registers AFTER the initial mount
|
||||
# call — but when we mount mid-flight the catch-all is already in
|
||||
# place, so the new ``/api/plugins/example/*`` route loses the
|
||||
# match-order race and we get a 404. Move the newly-appended routes
|
||||
# to the front of the list so FastAPI matches them first. They're
|
||||
# path-prefixed to ``/api/plugins/example/`` and can't shadow
|
||||
# anything else.
|
||||
new_routes = [r for r in app.router.routes if r not in original_routes]
|
||||
for route in new_routes:
|
||||
app.router.routes.remove(route)
|
||||
for offset, route in enumerate(new_routes):
|
||||
app.router.routes.insert(offset, route)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Restore the original route list — drops the example plugin's
|
||||
# routes so the next test sees a clean app — and clear the
|
||||
# cache for the same reason.
|
||||
app.router.routes[:] = original_routes
|
||||
web_server._dashboard_plugins_cache = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reload_env tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2551,12 +2643,284 @@ class TestNormaliseThemeExtensions:
|
||||
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
|
||||
|
||||
|
||||
class TestBulkDeleteSessionsEndpoint:
|
||||
"""Tests for ``POST /api/sessions/bulk-delete`` — backs the
|
||||
dashboard's "Delete N selected" flow on the sessions page.
|
||||
|
||||
Locks in four things:
|
||||
|
||||
1. Route-ordering: ``/api/sessions/bulk-delete`` must shadow the
|
||||
templated ``/api/sessions/{session_id}`` route below it (see
|
||||
the block comment in ``hermes_cli/web_server.py``).
|
||||
2. Behaviour parity with :meth:`SessionDB.delete_sessions` — real
|
||||
deleted count, archive/active sessions deleted on explicit
|
||||
selection.
|
||||
3. The 500-ID payload cap is enforced.
|
||||
4. Auth gating (issue #19533 contract).
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
|
||||
)
|
||||
|
||||
self.client = TestClient(app)
|
||||
self.auth_client = TestClient(app)
|
||||
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def _seed(self, ids):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
for sid in ids:
|
||||
db.create_session(session_id=sid, source="cli")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_requires_auth(self):
|
||||
resp = self.client.post("/api/sessions/bulk-delete", json={"ids": ["x"]})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_deletes_listed_sessions_only(self):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
self._seed(["a", "b", "c"])
|
||||
resp = self.auth_client.post(
|
||||
"/api/sessions/bulk-delete", json={"ids": ["a", "b"]}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "deleted": 2}
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
assert db.get_session("a") is None
|
||||
assert db.get_session("b") is None
|
||||
assert db.get_session("c") is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_unknown_ids_silently_skipped(self):
|
||||
"""The endpoint never 404s on a missing ID — it returns the
|
||||
real deleted count so a UI selection that raced against
|
||||
another tab still resolves cleanly."""
|
||||
self._seed(["real"])
|
||||
resp = self.auth_client.post(
|
||||
"/api/sessions/bulk-delete",
|
||||
json={"ids": ["real", "ghost1", "ghost2"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "deleted": 1}
|
||||
|
||||
def test_empty_list_is_noop(self):
|
||||
"""``ids: []`` returns ``deleted: 0`` (200, not 400) — the UI
|
||||
treats an empty selection as a no-op rather than an error."""
|
||||
resp = self.auth_client.post(
|
||||
"/api/sessions/bulk-delete", json={"ids": []}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "deleted": 0}
|
||||
|
||||
def test_payload_cap_enforced(self):
|
||||
"""501 IDs returns 400 — a hard cap stops a runaway selection
|
||||
from holding the SQLite writer for an extended window."""
|
||||
resp = self.auth_client.post(
|
||||
"/api/sessions/bulk-delete",
|
||||
json={"ids": [f"s{i}" for i in range(501)]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# 500 exactly still succeeds (no rows actually present, so
|
||||
# deleted=0 — but it's not the cap path).
|
||||
resp = self.auth_client.post(
|
||||
"/api/sessions/bulk-delete",
|
||||
json={"ids": [f"s{i}" for i in range(500)]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_route_order_not_shadowed_by_session_id(self):
|
||||
"""Pin the route-ordering contract: ``POST /api/sessions/bulk-delete``
|
||||
must hit the bulk handler, not be re-interpreted via the
|
||||
templated ``/api/sessions/{session_id}`` family. Concretely the
|
||||
response carries our ``ok`` + ``deleted`` keys."""
|
||||
resp = self.auth_client.post(
|
||||
"/api/sessions/bulk-delete", json={"ids": []}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body.get("ok") is True
|
||||
assert "deleted" in body, (
|
||||
"If this assertion fails, /api/sessions/bulk-delete is "
|
||||
"being shadowed by /api/sessions/{session_id} — check "
|
||||
"registration order in hermes_cli/web_server.py."
|
||||
)
|
||||
|
||||
|
||||
class TestDeleteEmptySessionsEndpoint:
|
||||
"""Tests for ``GET /api/sessions/empty/count`` and
|
||||
``DELETE /api/sessions/empty`` — the bulk-delete endpoints backing
|
||||
the dashboard's "Delete empty" button.
|
||||
|
||||
Locks in three things the implementation has to get right:
|
||||
|
||||
1. Route-ordering: the literal ``/api/sessions/empty[/count]`` paths
|
||||
must shadow the templated ``/api/sessions/{session_id}`` route
|
||||
above them. A regression here would route ``DELETE /api/sessions/
|
||||
empty`` to the single-session handler with ``session_id="empty"``
|
||||
(which 404s instead of bulk-deleting).
|
||||
2. Behaviour parity with :meth:`SessionDB.delete_empty_sessions`:
|
||||
active sessions and archived sessions are both preserved.
|
||||
3. Auth gating: both routes require the session token like every
|
||||
other ``/api/*`` endpoint (issue #19533 contract).
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
# Pin the SessionDB to the isolated HERMES_HOME so each test
|
||||
# starts with a clean state.db.
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
|
||||
)
|
||||
|
||||
self.client = TestClient(app)
|
||||
self.auth_client = TestClient(app)
|
||||
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def _seed(self):
|
||||
"""Build the standard test corpus:
|
||||
|
||||
* ``empty1`` / ``empty2`` — ended, no messages → should delete
|
||||
* ``hasmsg`` — ended, has one message → must survive
|
||||
* ``live`` — un-ended, empty → must survive (active)
|
||||
* ``archived``— ended, empty, archived → must survive
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="empty1", source="cli")
|
||||
db.end_session("empty1", end_reason="done")
|
||||
db.create_session(session_id="empty2", source="cli")
|
||||
db.end_session("empty2", end_reason="done")
|
||||
|
||||
db.create_session(session_id="hasmsg", source="cli")
|
||||
db.append_message("hasmsg", role="user", content="hello")
|
||||
db.end_session("hasmsg", end_reason="done")
|
||||
|
||||
db.create_session(session_id="live", source="cli")
|
||||
|
||||
db.create_session(session_id="archived", source="cli")
|
||||
db.end_session("archived", end_reason="done")
|
||||
db.set_session_archived("archived", True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_count_endpoint_requires_auth(self):
|
||||
"""GET /api/sessions/empty/count must 401 without the session token."""
|
||||
resp = self.client.get("/api/sessions/empty/count")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_delete_endpoint_requires_auth(self):
|
||||
"""DELETE /api/sessions/empty must 401 without the session token.
|
||||
|
||||
Regression guard for issue #19533 — the bulk-delete is a strictly
|
||||
destructive primitive, the middleware must gate it even if a
|
||||
future refactor introduces a non-auth path."""
|
||||
resp = self.client.delete("/api/sessions/empty")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_count_returns_only_empty_ended_unarchived(self):
|
||||
"""With the standard corpus, the count is exactly 2 — only
|
||||
``empty1`` and ``empty2`` qualify (``hasmsg`` has a message,
|
||||
``live`` is active, ``archived`` is archived)."""
|
||||
self._seed()
|
||||
resp = self.auth_client.get("/api/sessions/empty/count")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"count": 2}
|
||||
|
||||
def test_delete_returns_count_and_removes_only_empties(self):
|
||||
"""DELETE returns the deleted count and removes only the
|
||||
empty-ended-unarchived rows — same shape contract as the
|
||||
DB-level method's unit tests."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
self._seed()
|
||||
resp = self.auth_client.delete("/api/sessions/empty")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "deleted": 2}
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
assert db.get_session("empty1") is None
|
||||
assert db.get_session("empty2") is None
|
||||
# Survivors: hasmsg has a message, live is active, archived
|
||||
# is archived. All three must still be there.
|
||||
assert db.get_session("hasmsg") is not None
|
||||
assert db.get_session("live") is not None
|
||||
assert db.get_session("archived") is not None
|
||||
# And the count endpoint now reports 0.
|
||||
assert db.count_empty_sessions() == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_delete_with_no_empties_returns_zero(self):
|
||||
"""No empty sessions → endpoint returns ``deleted: 0`` (200,
|
||||
not 404). The dashboard relies on this no-op path to surface
|
||||
a "Nothing to clean up" toast instead of an error."""
|
||||
resp = self.auth_client.delete("/api/sessions/empty")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "deleted": 0}
|
||||
|
||||
def test_route_order_empty_not_shadowed_by_session_id(self):
|
||||
"""Pin the route-ordering contract: ``DELETE /api/sessions/empty``
|
||||
must hit the bulk handler, not the templated single-session
|
||||
handler (which would 404 because no session has id 'empty').
|
||||
|
||||
Concretely: a request against the bulk path on an EMPTY corpus
|
||||
returns ``{ok: True, deleted: 0}``. If the templated route were
|
||||
winning, we'd see 404 ("Session not found") instead.
|
||||
"""
|
||||
resp = self.auth_client.delete("/api/sessions/empty")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "deleted" in body, (
|
||||
"If this assertion fails, the literal /api/sessions/empty "
|
||||
"route is being shadowed by the templated /api/sessions/"
|
||||
"{session_id} route — check registration order in "
|
||||
"hermes_cli/web_server.py."
|
||||
)
|
||||
|
||||
|
||||
class TestPluginAPIAuth:
|
||||
"""Tests that plugin API routes require the session token (issue #19533)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
|
||||
"""Create a TestClient without the session token header."""
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
|
||||
"""Create a TestClient without the session token header.
|
||||
|
||||
Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth``
|
||||
has the ``/api/plugins/example/hello`` endpoint available — the
|
||||
example plugin is no longer a bundled plugin, so the fixture
|
||||
installs it into the per-test ``HERMES_HOME``.
|
||||
"""
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
@@ -2581,10 +2945,12 @@ class TestPluginAPIAuth:
|
||||
def test_plugin_route_allows_auth(self):
|
||||
"""Plugin API routes should work with a valid session token.
|
||||
|
||||
Use ``/api/plugins/example/hello`` from the example-dashboard plugin —
|
||||
a stable, side-effect-free GET that's always loaded in tests. With a
|
||||
valid token the handler should run (200); without one the middleware
|
||||
should 401 before the handler is reached.
|
||||
Uses ``/api/plugins/example/hello`` from the example-dashboard
|
||||
test fixture (installed into HERMES_HOME by the class-level
|
||||
``_install_example_plugin`` fixture) — a stable, side-effect-free
|
||||
GET that's only loaded for tests. With a valid token the handler
|
||||
should run (200); without one the middleware should 401 before
|
||||
the handler is reached.
|
||||
"""
|
||||
# Without auth: middleware blocks before reaching the handler.
|
||||
resp = self.client.get("/api/plugins/example/hello")
|
||||
@@ -3136,7 +3502,16 @@ class TestDashboardPluginStaticAssetAllowlist:
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
|
||||
"""Create a TestClient and install the example-dashboard fixture.
|
||||
|
||||
The static-asset allowlist tests need a plugin to point at —
|
||||
they verify that ``/dashboard-plugins/example/manifest.json``
|
||||
is served while ``plugin_api.py`` and ``__pycache__/*.pyc``
|
||||
from the same directory are not. Since the example plugin is
|
||||
no longer bundled, ``_install_example_plugin`` lays it down in
|
||||
the per-test ``HERMES_HOME`` user-plugins dir.
|
||||
"""
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
|
||||
Reference in New Issue
Block a user