mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 13:56:36 -10:00
Compare commits
No files matched your search
+1
-1
@@ -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"}
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.128"
|
||||
__version__ = "0.3.129"
|
||||
@@ -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 (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user