Compare commits

...
17 Commits
Author SHA1 Message Date
MervinPraison 2fb299a454 Release v0.3.129 2026-07-09 17:05:28 +00:00
praisonai-triage-agent[bot] e288fca9ca Merge pull request #184 from MervinPraison/claude/issue-174-20260708-0849
fix: dashboard generator emits main before shellHeader insertBefore (#174)
2026-07-09 16:53:45 +00:00
praisonai-triage-agent[bot] f3dd9b218d Merge pull request #182 from MervinPraison/claude/issue-177-20260708-120000
feat: surface user-visible errors for offline chat send
2026-07-09 16:51:10 +00:00
praisonai-triage-agent[bot] 949a1862da Merge pull request #181 from MervinPraison/claude/issue-178-20260708-120000
fix: expose Ctrl+K command palette globally + guard init idempotency
2026-07-09 16:47:38 +00:00
praisonai-triage-agent[bot]andMervin Praison 7167546900 test: align shell-error assertions with showDashboardInitError after rebase
The rebase onto main kept main's showDashboardInitError() helper, which
supersedes this PR's showShellError() and renders the same .db-error
banner with role=alert. Update the RC-2 assertions to match the surviving
helper name; UX guarantee (visible shell error on missing #db-main-content)
is unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 16:10:20 +00:00
MervinPraisonandClaude e029f6e063 feat: surface user-visible errors for offline chat send (fixes #177)
Add defensive shell error UI in dashboard.js selectPage, offline-aware
HTTP fallback messaging with toast, role=alert on system error bubbles,
and a try/catch guard around ws.send in chat.js sendMessage.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:06:25 +01:00
praisonai-triage-agent[bot] 32e8552840 Merge pull request #173 from MervinPraison/claude/issue-172-20260630-120000
fix: dark-theme dropdown contrast and sidebar header overlap
2026-07-09 16:04:43 +00:00
Mervin Praison e31df90d0e Merge pull request #179 from MervinPraison/claude/issue-175-20260708-000000
fix: surface selectPage error when dashboard shell incomplete
2026-07-09 17:04:40 +01:00
praisonai-triage-agent[bot] 58c66b65e5 Merge pull request #180 from MervinPraison/claude/issue-176-20260708-120000
fix: redact channel secrets in all API responses
2026-07-09 16:04:10 +00:00
praisonai-triage-agent[bot]andMervin Praison f0c298802b fix: emit main before shellHeader insertBefore in dashboard generator (fixes #174)
The dashboard.js generator emitted root.insertBefore(shellHeader, main)
before root.appendChild(main), so regenerating the bundle re-introduced
the #166/#174 insertBefore NotFoundError. Reorder the generator to append
main first, and lock it with a source-parsing regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 17:00:21 +01:00
praisonai-triage-agent[bot]andMervin Praison 45a5f0cd2d test: align session-search Ctrl+K test with command-palette ownership
The Ctrl+K/Cmd+K binding moved to initCommandPalette; initSessionSearch
now only owns Escape-to-close. Update test_ctrl_k_keydown_handler_present
to assert the current architecture instead of the outdated contract.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 17:00:18 +01:00
MervinPraisonandClaude 1b0e1646c9 fix: expose command palette globally and guard init idempotency (fixes #178)
BQA-002 (insertBefore) and the base command palette are already present in
the shipped dashboard bundle. This closes the remaining acceptance gaps:
openCommandPalette is now reachable in the global scope
(typeof openCommandPalette === 'function') and initCommandPalette no longer
double-registers its keydown handler on HMR / full reload.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:18 +01:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 2fe1a83c9a fix(ci): guard find_spec so missing praisonai parent pkg skips instead of erroring
find_spec("praisonai.jobs") raises ModuleNotFoundError (not returns None)
when the parent 'praisonai' package is absent, aborting collection of the
entire tests/unit suite in CI. Wrap in a helper that treats the missing
parent as unavailable so the SDK test skips cleanly.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-07-09 17:00:15 +01:00
praisonai-triage-agent[bot]andMervin Praison 971033b905 fix(channels): align write-path secret validation with read-path redaction
_validate_config_security used the narrow exact-match SECRET_FIELD_PATTERNS
while _redact_config_secrets was hardened to also catch *_token/*_key/*_secret/
*_password suffix keys. This closed the read leak but still let inline
app_token/signing_secret/client_secret values be stored on write. Route both
paths through the shared _is_secret_key helper so suffix secrets are rejected
at write time too, and add regression tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 17:00:15 +01:00
MervinPraisonandClaude 0626d0bb96 fix: redact channel secrets in all API responses (fixes #176)
GET /api/channels and related handlers serialized the in-memory _channels
dict verbatim, exposing config.bot_token and other secrets. Harden
_redact_config_secrets to cover *_token/*_key/*_secret/*_password suffix
keys (app_token, signing_secret, client_secret) and emit a <key>_set flag,
and redact the previously-unredacted _toggle response. Write path still
accepts and uses real tokens for bot startup.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:15 +01:00
MervinPraisonandClaude 581df433ed fix: surface selectPage error when dashboard shell incomplete (fixes #175)
Replace the silent `if (!main || !page) return;` early return in
selectPage() with explicit guards that log a console error and, when
#db-main-content is absent, render a visible db-error banner via the new
showDashboardInitError helper.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:13 +01:00
MervinPraisonandClaude bead6e4542 fix: dark-theme dropdown contrast and sidebar header overlap (fixes #172)
Native <select> option popups inherited the dashboard light foreground
while the browser painted a white system surface, producing unreadable
white-on-white platform labels. Add explicit dark-surface/light-text
styling for .db-form-select option and any dashboard select option, plus
an accent highlight for the checked option.

Harden the sidebar header so a long brand title truncates with an
ellipsis and reserves space for the toggle, preventing text overlap.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:11 +01:00
15 changed files with 674 additions and 20 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.128"
version = "0.3.129"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+2 -2
View File
@@ -199,11 +199,11 @@ text = text.replace(
root.appendChild(sidebar);
setupMobileNav(root, sidebar);
}
root.appendChild(main);
const shellHeader = document.createElement('div');
shellHeader.id = 'db-shell-header-slot';
renderSlot('shell:header', shellHeader);
if (shellHeader.children.length) root.insertBefore(shellHeader, main);
root.appendChild(main);""",
if (shellHeader.children.length) root.insertBefore(shellHeader, main);""",
)
# 6) selectPage toolbar
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.128"
__version__ = "0.3.129"
+31 -6
View File
@@ -76,6 +76,17 @@ SECRET_FIELD_PATTERNS = {
"private_key", "access_token", "refresh_token", "webhook_secret"
}
# Suffixes that identify secret-bearing config keys not covered by the
# exact-match SECRET_FIELD_PATTERNS set (e.g. app_token, signing_secret).
_SECRET_KEY_SUFFIXES = ("_token", "_key", "_secret", "_password")
def _is_secret_key(key: str) -> bool:
"""True if a config key holds a secret and must be redacted on read."""
lowered = key.lower()
return lowered in SECRET_FIELD_PATTERNS or lowered.endswith(_SECRET_KEY_SUFFIXES)
def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
"""Validate channel config doesn't contain inline secrets.
@@ -84,7 +95,7 @@ def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
if not isinstance(config, dict):
return None
for key, value in config.items():
if key.lower() in SECRET_FIELD_PATTERNS:
if _is_secret_key(key):
if isinstance(value, str) and value and not value.startswith("env:"):
# Check for common secret patterns
if (value.startswith(("sk-", "xoxb-", "xoxp-", "xapp-", "am_", "bot")) or
@@ -93,15 +104,25 @@ def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
f"'{key}_ref': 'env:YOUR_ENV_VAR'")
return None
def _redact_config_secrets(config: Dict[str, Any]) -> Dict[str, Any]:
"""Redact secret values in config for API responses."""
"""Redact secret values in config for API responses.
Any key matching a known secret name or a secret-bearing suffix is
replaced with ``***REDACTED***`` and a ``<key>_set`` boolean is added so
the UI can indicate whether a value is configured without exposing it.
Values referencing env vars (``env:...``) are left as-is — they contain
no literal secret.
"""
if not isinstance(config, dict):
return config
redacted = config.copy()
for key, value in config.items():
if key.lower() in SECRET_FIELD_PATTERNS and isinstance(value, str) and value:
if not value.startswith("env:"):
redacted[key] = "***REDACTED***"
if _is_secret_key(key) and isinstance(value, str) and value:
if value.startswith("env:"):
continue
redacted[key] = "***REDACTED***"
redacted[f"{key}_set"] = True
return redacted
def _get_config_secret(config: Dict[str, Any], key: str, env_fallback: Optional[str] = None) -> str:
@@ -1030,7 +1051,11 @@ class ChannelsFeature(BaseFeatureProtocol):
channel["start_error"] = error
else:
await self._stop_channel_bot(channel_id)
return JSONResponse(channel)
# Redact secrets in response
safe_channel = channel.copy()
safe_channel["config"] = _redact_config_secrets(channel.get("config", {}))
return JSONResponse(safe_channel)
async def _status(self, request: Request) -> JSONResponse:
channel_id = request.path_params["channel_id"]
@@ -316,8 +316,12 @@ const DASHBOARD_STYLE = `
padding: 20px; font-size: 15px; font-weight: 600;
letter-spacing: -0.01em; border-bottom: 1px solid var(--db-border);
display: flex; align-items: center; gap: 10px;
min-width: 0; padding-right: 28px;
}
.db-sidebar-header .logo { font-size: 20px; }
.db-sidebar-header > span:not(.logo) {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.db-sidebar-header .logo { font-size: 20px; flex-shrink: 0; }
.db-sidebar-toggle {
position: absolute;
right: -12px;
@@ -480,6 +484,9 @@ const DASHBOARD_STYLE = `
@keyframes db-spin { to { transform: rotate(360deg); } }
.db-spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid var(--db-border); border-top-color: var(--db-accent); border-radius: 50%; animation: db-spin 0.8s linear infinite; }
/* Init error banner */
.db-error { margin: 24px; padding: 16px 20px; border: 1px solid #b91c1c; border-radius: 8px; background: rgba(185, 28, 28, 0.12); color: #fca5a5; font-size: 14px; }
/* Hide React docs layout when dashboard is active */
.db-active .sidebar:not(.db-sidebar), .db-active .topnav, .db-active .toc-sidebar,
.db-active > .main-content, .db-active > nav:not(.db-sidebar) { display: none !important; }
@@ -583,6 +590,22 @@ const DASHBOARD_STYLE = `
appearance: none; cursor: pointer;
}
.db-form-select:focus { border-color: var(--db-accent); }
.db-form-select option, select.db-form-select option {
background-color: var(--db-sidebar-bg);
color: var(--db-text);
}
.db-form-select option:checked, select.db-form-select option:checked {
background-color: var(--db-accent);
color: #fff;
}
.db-active select option, #root select option {
background-color: var(--db-sidebar-bg);
color: var(--db-text);
}
.db-active select option:checked, #root select option:checked {
background-color: var(--db-accent);
color: #fff;
}
.db-form-textarea {
width: 100%; padding: 8px 12px; font-size: 13px; color: var(--db-text);
background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: 6px;
@@ -1064,7 +1087,11 @@ function closeCommandPalette() {
_paletteResults = [];
}
let _paletteInitialized = false;
function initCommandPalette() {
if (_paletteInitialized) return;
_paletteInitialized = true;
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
@@ -1073,6 +1100,11 @@ function initCommandPalette() {
});
}
window.aiui = window.aiui || {};
window.aiui.openCommandPalette = openCommandPalette;
window.aiui.closeCommandPalette = closeCommandPalette;
window.openCommandPalette = openCommandPalette;
// ── Session Search Palette ──────────────────────────────────────────
// Vanilla-JS port of the React SessionSearch / useSessionSearch behavior so
// that the dashboard style reaches C-01 parity. The palette lists sessions
@@ -1495,6 +1527,20 @@ function buildSidebar(pages) {
return sidebar;
}
function showDashboardInitError(message) {
const root = document.getElementById('root');
if (!root) return;
let banner = document.getElementById('db-init-error');
if (!banner) {
banner = document.createElement('div');
banner.id = 'db-init-error';
banner.className = 'db-error';
banner.setAttribute('role', 'alert');
root.appendChild(banner);
}
banner.textContent = message;
}
async function selectPage(pageId) {
// Cleanup previous view if it has a cleanup function
if (_activeCleanup) { try { _activeCleanup(); } catch(e) {} _activeCleanup = null; }
@@ -1513,7 +1559,15 @@ async function selectPage(pageId) {
const page = pagesData.find(p => p.id === pageId);
const main = document.getElementById('db-main-content');
if (!main || !page) return;
if (!main) {
console.error('[AIUI] selectPage aborted: #db-main-content not in DOM — dashboard shell incomplete');
showDashboardInitError('Dashboard shell layout incomplete. Reload the page or check the dashboard plugin.');
return;
}
if (!page) {
console.error(`[AIUI] selectPage aborted: page '${pageId}' not found in pagesData`);
return;
}
// Set page header (controlled by dashboard config protocol)
const showPageHeader = main.dataset.showPageHeader !== 'false';
@@ -9,6 +9,8 @@
* Config-driven: auto-discovers agents and sessions from existing APIs.
*/
import { showToast } from '../toast.js';
// ── State ────────────────────────────────────────────────────────
let ws = null;
let currentSessionId = null;
@@ -1063,6 +1065,7 @@ function connectWebSocket() {
ws.onerror = (e) => {
console.error('[Chat] WebSocket error:', e);
setStatus('error');
};
} catch (e) {
console.warn('[Chat] WebSocket connection failed:', e);
@@ -1359,6 +1362,9 @@ function appendMessage(role, content, agentName, elements) {
const msgEl = document.createElement('div');
msgEl.className = 'chat-msg chat-msg-' + role;
if (role === 'system' && typeof content === 'string' && content.startsWith('❌')) {
msgEl.setAttribute('role', 'alert');
}
const avatarEl = document.createElement('div');
avatarEl.className = 'chat-msg-avatar';
@@ -1907,12 +1913,20 @@ function sendMessage() {
if (pendingAttachments.length > 0) {
payload.attachment_ids = pendingAttachments.map(a => a.id);
}
ws.send(JSON.stringify(payload));
setStreaming(true);
setStatus('streaming');
try {
ws.send(JSON.stringify(payload));
setStreaming(true);
setStatus('streaming');
} catch (e) {
const msg = 'Connection lost. Message not sent.';
appendMessage('system', '❌ ' + msg);
setStatus('error');
try { showToast(msg, 'error'); } catch (_) {}
}
} else {
// Fallback to HTTP
setStatus('sending…');
const offlineHint = (typeof navigator !== 'undefined' && navigator.onLine === false) ? 'You are offline. ' : '';
fetch('/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1925,13 +1939,19 @@ function sendMessage() {
}).then((r) => r.json()).then((data) => {
if (data.error) {
appendMessage('system', '❌ ' + data.error);
setStatus('error');
try { showToast(data.error, 'error'); } catch (_) {}
} else if (data.content || data.response) {
appendMessage('assistant', data.content || data.response, data.agent_name);
setStatus('connected');
} else {
setStatus('connected');
}
setStatus('connected');
}).catch((e) => {
appendMessage('system', '❌ ' + e.message);
const msg = offlineHint + (e.message || 'Network error');
appendMessage('system', '❌ ' + msg);
setStatus('error');
try { showToast(msg, 'error'); } catch (_) {}
});
}
+222
View File
@@ -0,0 +1,222 @@
"""Tests for channel secret redaction in API responses.
Regression coverage for issue #176 (recurrence of #58): ``GET /api/channels``
and related handlers serialized the in-memory ``_channels`` dict verbatim,
exposing ``config.bot_token`` and other secrets in the browser Network panel
without authentication. Read handlers must redact secrets while the write path
still accepts and uses the real token for bot startup.
"""
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from praisonaiui.features import channels as channels_mod
from praisonaiui.features.channels import (
ChannelsFeature,
_redact_config_secrets,
_validate_config_security,
)
_DISCORD_TOKEN = "MTQ" + "a1b2c3d4e5f6g7h8i9j0k1l2"
class _Req:
def __init__(self, path_params=None, body=None):
self.path_params = path_params or {}
self._body = body or {}
async def json(self):
return self._body
@pytest.fixture(autouse=True)
def _isolate_channel_state():
saved_channels = dict(channels_mod._channels)
saved_bots = dict(channels_mod._live_bots)
saved_flag = ChannelsFeature._auto_started
channels_mod._channels.clear()
channels_mod._live_bots.clear()
ChannelsFeature._auto_started = True
yield
channels_mod._channels.clear()
channels_mod._channels.update(saved_channels)
channels_mod._live_bots.clear()
channels_mod._live_bots.update(saved_bots)
ChannelsFeature._auto_started = saved_flag
def _seed_channel():
entry = {
"id": "e4a6d6e1d0cf",
"name": "DhivyaBot",
"platform": "discord",
"enabled": True,
"running": True,
"config": {"bot_token": _DISCORD_TOKEN},
}
channels_mod._channels[entry["id"]] = entry
return entry
def _body(resp):
return json.loads(resp.body)
# ── Redaction helper ─────────────────────────────────────────────────
def test_redact_covers_suffix_secret_keys():
out = _redact_config_secrets(
{
"bot_token": _DISCORD_TOKEN,
"app_token": "xapp-1-abc",
"signing_secret": "shh",
"client_secret": "shh2",
"api_key": "k",
"webhook_url": "https://example.com/hook",
}
)
assert out["bot_token"] == "***REDACTED***"
assert out["app_token"] == "***REDACTED***"
assert out["signing_secret"] == "***REDACTED***"
assert out["client_secret"] == "***REDACTED***"
assert out["api_key"] == "***REDACTED***"
assert out["webhook_url"] == "https://example.com/hook"
assert out["bot_token_set"] is True
assert out["app_token_set"] is True
def test_redact_preserves_env_references():
out = _redact_config_secrets({"bot_token": "env:DISCORD_BOT_TOKEN"})
assert out["bot_token"] == "env:DISCORD_BOT_TOKEN"
assert "bot_token_set" not in out
def test_redact_is_non_mutating():
original = {"bot_token": _DISCORD_TOKEN}
_redact_config_secrets(original)
assert original["bot_token"] == _DISCORD_TOKEN
# ── Write path rejects inline secrets on suffix keys ─────────────────
def test_validate_rejects_inline_suffix_secret():
err = _validate_config_security({"app_token": "xapp-1-" + "a" * 30})
assert err is not None
assert "app_token" in err
def test_validate_allows_env_reference_suffix_key():
assert _validate_config_security({"signing_secret": "env:SLACK_SIGNING_SECRET"}) is None
# ── Read handlers must not leak ──────────────────────────────────────
@pytest.mark.asyncio
async def test_list_redacts_bot_token():
_seed_channel()
feature = ChannelsFeature()
with patch.object(feature, "_sync_running_status"):
resp = await feature._list(_Req())
body = _body(resp)
assert _DISCORD_TOKEN not in json.dumps(body)
cfg = body["channels"][0]["config"]
assert cfg["bot_token"] == "***REDACTED***"
assert cfg["bot_token_set"] is True
@pytest.mark.asyncio
async def test_get_redacts_bot_token():
entry = _seed_channel()
feature = ChannelsFeature()
with patch.object(feature, "_sync_running_status"):
resp = await feature._get(_Req(path_params={"channel_id": entry["id"]}))
body = _body(resp)
assert _DISCORD_TOKEN not in json.dumps(body)
assert body["config"]["bot_token"] == "***REDACTED***"
@pytest.mark.asyncio
async def test_add_response_redacts_bot_token():
feature = ChannelsFeature()
req = _Req(
body={
"id": "newch",
"name": "New",
"platform": "discord",
"config": {"bot_token": "env:DISCORD_BOT_TOKEN"},
}
)
with patch.object(feature, "_start_channel_bot", return_value=None), patch.object(
feature, "_sync_running_status"
), patch.object(channels_mod, "_persist_channels"):
resp = await feature._add(req)
body = _body(resp)
assert resp.status_code == 201
assert _DISCORD_TOKEN not in json.dumps(body)
assert body["config"]["bot_token"] == "env:DISCORD_BOT_TOKEN"
@pytest.mark.asyncio
async def test_update_response_redacts_bot_token():
entry = _seed_channel()
feature = ChannelsFeature()
req = _Req(
path_params={"channel_id": entry["id"]},
body={"name": "Renamed"},
)
with patch.object(channels_mod, "_persist_channels"):
resp = await feature._update(req)
body = _body(resp)
assert body["name"] == "Renamed"
assert _DISCORD_TOKEN not in json.dumps(body)
assert body["config"]["bot_token"] == "***REDACTED***"
@pytest.mark.asyncio
async def test_toggle_response_redacts_bot_token():
entry = _seed_channel()
feature = ChannelsFeature()
req = _Req(path_params={"channel_id": entry["id"]})
with patch.object(feature, "_stop_channel_bot", return_value=None):
resp = await feature._toggle(req)
body = _body(resp)
assert _DISCORD_TOKEN not in json.dumps(body)
assert body["config"]["bot_token"] == "***REDACTED***"
# ── Write path must keep the real secret internally ──────────────────
@pytest.mark.asyncio
async def test_internal_store_retains_real_token_after_read():
entry = _seed_channel()
feature = ChannelsFeature()
with patch.object(feature, "_sync_running_status"):
await feature._list(_Req())
assert channels_mod._channels[entry["id"]]["config"]["bot_token"] == _DISCORD_TOKEN
@pytest.mark.asyncio
async def test_start_channel_bot_reads_unredacted_token():
entry = _seed_channel()
feature = ChannelsFeature()
captured: dict = {}
def _fake_direct(platform, token, agent, config):
captured["token"] = token
return None
with patch.object(feature, "_get_gateway", return_value=None), patch.object(
feature, "_create_bot_direct", side_effect=_fake_direct
):
await feature._start_channel_bot(entry["id"], entry)
assert captured["token"] == _DISCORD_TOKEN
+82
View File
@@ -0,0 +1,82 @@
"""Regression tests for #177 — offline chat send produces no user-visible error.
The browser E2E audit simulated offline mode on /chat and found no visible
error feedback. These source-parsing tests assert the fixes:
RC-2 selectPage shows a shell error (.db-error / role=alert via
showDashboardInitError) when #db-main-content is missing instead
of returning silently.
RC-3 the HTTP fallback catch path surfaces an offline hint plus a toast.
RC-3b system error bubbles (prefixed with ❌) get role="alert" for a11y.
RC-4 ws.send() is wrapped in try/catch with a user-visible error.
"""
from __future__ import annotations
from pathlib import Path
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
DASHBOARD_JS = FRONTEND / "dashboard.js"
CHAT_JS = FRONTEND / "views" / "chat.js"
class TestSelectPageShellError:
def test_shell_error_helper_defined(self):
src = DASHBOARD_JS.read_text()
assert "function showDashboardInitError(" in src
assert "'alert'" in src
assert "db-error" in src
def test_select_page_calls_shell_error_on_missing_main(self):
src = DASHBOARD_JS.read_text()
idx = src.find("const main = document.getElementById('db-main-content')")
assert idx != -1
region = src[idx:idx + 400]
assert "if (!main)" in region
assert "showDashboardInitError(" in region
class TestChatOfflineMessaging:
def test_toast_imported(self):
src = CHAT_JS.read_text()
assert "import { showToast } from '../toast.js';" in src
def test_offline_hint_in_fallback(self):
src = CHAT_JS.read_text()
assert "navigator.onLine === false" in src
assert "You are offline. " in src
def test_fallback_catch_shows_toast(self):
src = CHAT_JS.read_text()
idx = src.find("fetch('/api/chat/send'")
assert idx != -1
region = src[idx:idx + 900]
assert ".catch(" in region
assert "showToast(" in region
assert "appendMessage('system'" in region
assert "setStatus('error')" in region
class TestSystemErrorRoleAlert:
def test_append_message_sets_role_alert(self):
src = CHAT_JS.read_text()
idx = src.find("function appendMessage(")
assert idx != -1
region = src[idx:idx + 700]
assert "setAttribute('role', 'alert')" in region
assert "startsWith('❌')" in region
class TestWebSocketSendGuard:
def test_ws_send_wrapped_in_try_catch(self):
src = CHAT_JS.read_text()
idx = src.find("ws.send(JSON.stringify(payload))")
assert idx != -1
before = src[max(0, idx - 120):idx]
after = src[idx:idx + 300]
assert "try {" in before
assert "catch" in after
assert "Connection lost. Message not sent." in after
+67
View File
@@ -0,0 +1,67 @@
"""Regression tests for #178 — Ctrl+K command palette exposure and idempotency.
The dashboard command palette (initCommandPalette / openCommandPalette /
.db-cmdk-overlay) is implemented in the shipped bundle, but the acceptance
criteria require openCommandPalette to be reachable from the global scope
(``typeof openCommandPalette === 'function'``) and require that repeated
initialisation (HMR / full reload) does not double-register the keydown
handler. These source-parsing tests mirror ``test_dashboard_shell_order.py``.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
DASHBOARD_JS = FRONTEND / "dashboard.js"
class TestCommandPalette:
def _src(self) -> str:
return DASHBOARD_JS.read_text()
def test_open_command_palette_exposed_globally(self):
src = self._src()
assert "window.openCommandPalette = openCommandPalette" in src, (
"openCommandPalette must be exposed on window for the global "
"'typeof openCommandPalette === function' acceptance criterion"
)
def test_open_command_palette_on_aiui_namespace(self):
src = self._src()
assert "window.aiui.openCommandPalette = openCommandPalette" in src
def test_init_command_palette_guarded_against_double_register(self):
src = self._src()
m = re.search(
r"function initCommandPalette\([^)]*\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "initCommandPalette not found"
body = m.group(1)
assert "_paletteInitialized" in body, (
"initCommandPalette must guard against duplicate registration"
)
assert "return" in body, (
"initCommandPalette must early-return when already initialised"
)
def test_init_command_palette_called_after_build(self):
src = self._src()
build_idx = src.find("await buildDashboard()")
init_idx = src.find("initCommandPalette();")
assert build_idx != -1, "buildDashboard call not found"
assert init_idx != -1, "initCommandPalette call not found"
assert build_idx < init_idx, (
"initCommandPalette must run after buildDashboard so the shell exists"
)
def test_ctrl_and_meta_k_bound(self):
src = self._src()
assert re.search(
r"e\.ctrlKey \|\| e\.metaKey", src
), "Ctrl+K / Cmd+K must both open the palette"
@@ -0,0 +1,77 @@
"""Regression tests for issue #172.
Two dark-theme defects on the dashboard shell:
1. Native ``<select>`` option popups inherited the dashboard's light
foreground colour while the browser painted a white system background,
producing unreadable white-on-white platform labels (WCAG AA failure).
2. The sidebar header brand text could overlap an adjacent control because
the long title had no overflow/ellipsis guard and the absolutely
positioned toggle reserved no space.
Both fixes live in the committed dashboard bundle
``src/praisonaiui/templates/frontend/plugins/dashboard.js`` (``DASHBOARD_STYLE``).
"""
from __future__ import annotations
import re
from pathlib import Path
DASHBOARD_JS = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend"
/ "plugins" / "dashboard.js"
)
def _style() -> str:
src = DASHBOARD_JS.read_text()
m = re.search(r"const DASHBOARD_STYLE = `(.*?)`;", src, re.DOTALL)
assert m, "DASHBOARD_STYLE template literal not found"
return m.group(1)
class TestSelectOptionContrast:
"""Native option popups must use a dark surface with light text."""
def test_form_select_option_has_explicit_colors(self):
style = _style()
m = re.search(r"\.db-form-select option[^{]*\{([^}]*)\}", style)
assert m, "no explicit styling for .db-form-select option"
body = m.group(1)
assert "background-color" in body
assert "color" in body
assert "var(--db-text)" in body
def test_generic_dashboard_select_option_styled(self):
style = _style()
assert ".db-active select option" in style or "#root select option" in style
def test_selected_option_is_highlighted(self):
style = _style()
assert "option:checked" in style
class TestSidebarHeaderNoOverlap:
"""Sidebar brand text must not overlap adjacent controls."""
def test_header_reserves_space_and_truncates(self):
style = _style()
m = re.search(r"\n \.db-sidebar-header\s*\{([^}]*)\}", style)
assert m, ".db-sidebar-header base rule not found"
body = m.group(1)
assert "min-width: 0" in body or "min-width:0" in body
assert "padding-right" in body
def test_header_label_has_ellipsis(self):
style = _style()
assert "text-overflow: ellipsis" in style
assert "white-space: nowrap" in style
def test_logo_does_not_shrink(self):
style = _style()
m = re.search(r"\.db-sidebar-header \.logo\s*\{([^}]*)\}", style)
assert m, ".db-sidebar-header .logo rule not found"
assert "flex-shrink: 0" in m.group(1)
@@ -0,0 +1,71 @@
"""Regression tests for #175 — selectPage() silent abort when #db-main-content missing.
The dashboard style (`dashboard.js`) skips React. When the shell layout is
incomplete and `#db-main-content` is absent, `selectPage()` used to return
silently, leaving every view empty with no console error or user-visible signal.
These source-parsing tests assert the defensive guard and error surface are
wired in, mirroring `test_session_search_dashboard.py`.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
DASHBOARD_JS = FRONTEND / "dashboard.js"
class TestSelectPageGuard:
def _select_page_body(self) -> str:
src = DASHBOARD_JS.read_text()
m = re.search(
r"async function selectPage\(pageId\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "selectPage not found"
return m.group(1)
def test_missing_main_logs_console_error(self):
body = self._select_page_body()
m = re.search(
r"if \(!main\)\s*\{(.*?)\}", body, re.DOTALL,
)
assert m, "guard for missing #db-main-content not found"
guard = m.group(1)
assert "console.error" in guard
assert "db-main-content" in guard
def test_missing_main_surfaces_init_error(self):
body = self._select_page_body()
m = re.search(
r"if \(!main\)\s*\{(.*?)\}", body, re.DOTALL,
)
assert m, "guard for missing #db-main-content not found"
assert "showDashboardInitError" in m.group(1)
def test_missing_page_logs_console_error(self):
body = self._select_page_body()
m = re.search(
r"if \(!page\)\s*\{(.*?)\}", body, re.DOTALL,
)
assert m, "guard for missing page not found"
assert "console.error" in m.group(1)
def test_show_dashboard_init_error_helper_present(self):
src = DASHBOARD_JS.read_text()
m = re.search(
r"function showDashboardInitError\(message\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "showDashboardInitError helper not found"
helper = m.group(1)
assert "db-init-error" in helper
assert "role" in helper and "alert" in helper
def test_no_silent_combined_return(self):
body = self._select_page_body()
assert "if (!main || !page) return;" not in body
+17 -2
View File
@@ -13,11 +13,12 @@ from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
DASHBOARD_JS = FRONTEND / "dashboard.js"
GENERATOR = ROOT / "scripts" / "patch_dashboard_modular.py"
class TestDashboardShellOrder:
@@ -48,3 +49,17 @@ class TestDashboardShellOrder:
body,
)
assert m, "shellHeader insert must be guarded by children.length"
def test_generator_emits_main_before_insert_before(self):
src = GENERATOR.read_text()
append_idx = src.find("root.appendChild(main);\n const shellHeader")
insert_idx = src.find("root.insertBefore(shellHeader, main)")
assert append_idx != -1, (
"generator must emit root.appendChild(main) before shellHeader"
)
assert insert_idx != -1, (
"generator must emit root.insertBefore(shellHeader, main)"
)
assert append_idx < insert_idx, (
"generator must append main to root before insertBefore references it"
)
+8
View File
@@ -3,11 +3,19 @@
from __future__ import annotations
import asyncio
import importlib.util
import pytest
from starlette.testclient import TestClient
def _praisonai_jobs_available() -> bool:
try:
return importlib.util.find_spec("praisonai.jobs") is not None
except ModuleNotFoundError:
return False
@pytest.fixture
def simple_jobs_client():
import praisonaiui.server as server
@@ -31,6 +31,9 @@ class TestDashboardSessionSearch:
# keydown listener physically lives.
src = self._src()
assert "initSessionSearch" in src
# Ctrl+K / Cmd+K ownership lives in the command palette
# (initCommandPalette), which exposes a "Search sessions…" entry that
# calls openSessionSearch. initSessionSearch only owns Escape-to-close.
m = re.search(
r"function initCommandPalette\(\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
@@ -45,6 +48,16 @@ class TestDashboardSessionSearch:
assert "openSessionSearch()" in src
assert "action: 'sessions'" in src or 'action: "sessions"' in src
# initSessionSearch owns only Escape-to-close for the session palette.
m = re.search(
r"function initSessionSearch\(\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "initSessionSearch not found"
assert "Escape" in m.group(1), (
"initSessionSearch must own Escape-to-close for the session palette"
)
def test_palette_fetches_sessions_endpoint(self):
src = self._src()
m = re.search(
Generated
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.128"
version = "0.3.129"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },