* Polish workspace panel behavior and app dialogs * Replace remaining emoji UI glyphs with Lucide icons * Redesign composer footer around model and context controls Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill. Design credit and inspiration: Theo / T3 Code. Reference implementation: https://github.com/pingdotgg/t3code/ * Remove obsolete activity bar Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts. This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status. * Move workspace and model switching into composer footer * Move profile switching into composer footer * Refactor Hermes control center UI * Redesign control center settings modal layout Widen the modal to 860px, simplify the tab list to icon+label rows, stretch the tab column's divider to full height, lock the panel to a fixed height so switching tabs no longer resizes the outer shell, and always open on the Conversation tab. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Put session item actions in a dropdown * Use Hermes mark in sidebar control button * Reset control center section on close * Drop session-item left border indicator Remove the left-border accent used for active, CLI, and project rows — each state already has a dedicated cue (gold fill, cli badge, project dot), so the border was redundant. Fully round the row, add 2px bottom spacing between rows, and strip the matching JS/CSS overrides. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Increase session search input vertical padding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Normalise odd pixel values across UI Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid across composer chips, sidebar panels, cron list, settings, approval buttons, dropdowns, and inline message edit — eliminating the 7/9/11px drift that was making sibling elements feel subtly misaligned. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite) The mobile layout regression suite (test_mobile_layout.py) requires: - #btnMobileFiles onclick=toggleMobileFiles() in topbar chips - .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports. * Improve composer footer mobile responsiveness and UX - Collapse composer chips to icon-only at <=400px viewports - Add model chip icon (CPU) so it remains tappable when labels are hidden - Show send button always (disabled state when empty, hidden during streaming) - Show context usage indicator on session load, not just after streaming - Add cancel status fallback timeout to prevent stale "Cancelling..." text - Update tests to match new send button and busy state behavior * Fix duplicate files button and broken workspace close on mobile Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle in the mobile topbar. Fix workspace panel close button calling undefined closeMobileFiles() — now calls closeWorkspacePanel(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix model chip icon vertical alignment in composer footer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix workspace toggle button hidden on desktop by conflicting CSS class Remove mobile-files-btn class from #btnWorkspacePanelToggle — its display:none!important rule was overriding workspace-toggle-btn visibility on non-mobile viewports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix session actions dots button inaccessible on mobile sidebar Always show the session actions trigger on mobile (no hover state on touch devices) and restore right padding so text truncates with ellipsis before the dots icon. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix composer footer manage links not opening sidebar panel The "Manage profiles" and "Manage workspaces" links in the composer footer dropdowns called switchPanel() which only changes the active panel content but doesn't open the sidebar. Replaced with mobileSwitchPanel() which also opens the sidebar so the panel is actually visible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Widen icon-only composer chips breakpoint from 400px to 768px Move the icon-only chip styling up into the existing max-width:768px media query so chips collapse to icon-only on tablets too, preventing composer footer overflow on mid-size screens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix composer-left vertical scrollbar by setting overflow-y:hidden When overflow-x is set to auto, the CSS spec implicitly changes overflow-y from visible to auto, allowing a vertical scrollbar to appear from slight chip padding/border overflow. Explicitly set overflow-y:hidden to prevent this. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve rebase conflicts and fix control center test assertions - Resolved 4 conflicts during rebase onto master (workspace.js, boot.js, index.html, test_sprint34.py) - Fixed test_sprint34.py: _controlSection -> _settingsSection, cc-tab -> settings-tabs (matching actual implementation) - Fixed quoting syntax error in test assertion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update version badge in System tab to v0.49.4 * docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge --------- Co-authored-by: Aron Prins <pwf.aron@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
245 lines
11 KiB
Python
245 lines
11 KiB
Python
"""
|
||
Sprint 34 Tests: OAuth provider support in onboarding (issues #303, #304).
|
||
|
||
Covers:
|
||
1. _provider_oauth_authenticated() returns True for known OAuth providers
|
||
with valid tokens in auth.json
|
||
2. _provider_oauth_authenticated() returns False when auth.json is absent,
|
||
empty, or has no token data
|
||
3. _provider_oauth_authenticated() returns False for unknown/API-key providers
|
||
4. _status_from_runtime() marks copilot/openai-codex as provider_ready when
|
||
credentials exist
|
||
5. _status_from_runtime() gives a helpful "hermes auth" note (not "API key")
|
||
for OAuth providers that have no credentials yet
|
||
6. API route /api/onboarding/status reflects OAuth-ready state
|
||
"""
|
||
|
||
import json
|
||
import pathlib
|
||
import tempfile
|
||
import unittest.mock
|
||
|
||
import pytest
|
||
|
||
REPO = pathlib.Path(__file__).parent.parent
|
||
BASE = "http://127.0.0.1:8788"
|
||
|
||
|
||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
def _make_auth_json(provider_id: str, tokens: dict, tmp_dir: pathlib.Path) -> pathlib.Path:
|
||
"""Write an auth.json with the given tokens for provider_id into tmp_dir."""
|
||
store = {"providers": {provider_id: tokens}}
|
||
auth_path = tmp_dir / "auth.json"
|
||
auth_path.write_text(json.dumps(store), encoding="utf-8")
|
||
return auth_path
|
||
|
||
|
||
# ── 1–3. _provider_oauth_authenticated unit tests ────────────────────────────
|
||
|
||
class TestProviderOAuthAuthenticated:
|
||
"""Unit tests for the new _provider_oauth_authenticated() helper."""
|
||
|
||
def _call(self, provider: str, hermes_home: pathlib.Path) -> bool:
|
||
# Import fresh so we don't get a stale module reference
|
||
from api.onboarding import _provider_oauth_authenticated
|
||
return _provider_oauth_authenticated(provider, hermes_home)
|
||
|
||
def test_returns_false_when_auth_json_absent(self, tmp_path):
|
||
"""No auth.json -> not authenticated."""
|
||
assert self._call("openai-codex", tmp_path) is False
|
||
|
||
def test_openai_codex_with_access_token(self, tmp_path):
|
||
"""openai-codex with a valid access_token -> authenticated."""
|
||
_make_auth_json(
|
||
"openai-codex",
|
||
{"access_token": "ey.test.token", "refresh_token": "ref123"},
|
||
tmp_path,
|
||
)
|
||
assert self._call("openai-codex", tmp_path) is True
|
||
|
||
def test_openai_codex_with_refresh_token_only(self, tmp_path):
|
||
"""openai-codex with only a refresh_token -> still authenticated."""
|
||
_make_auth_json(
|
||
"openai-codex",
|
||
{"access_token": "", "refresh_token": "ref_only_token"},
|
||
tmp_path,
|
||
)
|
||
assert self._call("openai-codex", tmp_path) is True
|
||
|
||
def test_copilot_with_api_key(self, tmp_path):
|
||
"""copilot with an api_key (GitHub token) -> authenticated."""
|
||
_make_auth_json("copilot", {"api_key": "ghu_test_token_123"}, tmp_path)
|
||
assert self._call("copilot", tmp_path) is True
|
||
|
||
def test_empty_tokens_returns_false(self, tmp_path):
|
||
"""All token fields empty -> not authenticated."""
|
||
_make_auth_json(
|
||
"openai-codex",
|
||
{"access_token": "", "refresh_token": "", "api_key": ""},
|
||
tmp_path,
|
||
)
|
||
assert self._call("openai-codex", tmp_path) is False
|
||
|
||
def test_missing_provider_key_in_auth_json(self, tmp_path):
|
||
"""auth.json present but provider key absent -> not authenticated."""
|
||
store = {"providers": {"some-other-provider": {"access_token": "tok"}}}
|
||
(tmp_path / "auth.json").write_text(json.dumps(store), encoding="utf-8")
|
||
assert self._call("openai-codex", tmp_path) is False
|
||
|
||
def test_unknown_provider_not_in_oauth_list(self, tmp_path):
|
||
"""A provider that is not a known OAuth provider -> always False."""
|
||
_make_auth_json("some-random-provider", {"access_token": "tok"}, tmp_path)
|
||
assert self._call("some-random-provider", tmp_path) is False
|
||
|
||
def test_nous_provider_recognized(self, tmp_path):
|
||
"""nous is in the known OAuth set."""
|
||
_make_auth_json("nous", {"access_token": "nous_tok"}, tmp_path)
|
||
assert self._call("nous", tmp_path) is True
|
||
|
||
def test_qwen_oauth_provider_recognized(self, tmp_path):
|
||
"""qwen-oauth is in the known OAuth set."""
|
||
_make_auth_json("qwen-oauth", {"access_token": "qwen_tok"}, tmp_path)
|
||
assert self._call("qwen-oauth", tmp_path) is True
|
||
|
||
def test_empty_provider_string_returns_false(self, tmp_path):
|
||
"""Empty provider string -> False, no crash."""
|
||
assert self._call("", tmp_path) is False
|
||
assert self._call(" ", tmp_path) is False
|
||
|
||
|
||
# ── 4–5. _status_from_runtime integration ────────────────────────────────────
|
||
|
||
class TestStatusFromRuntimeOAuth:
|
||
"""_status_from_runtime should treat OAuth providers with tokens as ready."""
|
||
|
||
def _call(self, provider: str, model: str, hermes_home: pathlib.Path) -> dict:
|
||
from api.onboarding import _status_from_runtime
|
||
import api.onboarding as _ob
|
||
orig_home = _ob._get_active_hermes_home
|
||
orig_found = _ob._HERMES_FOUND
|
||
_ob._get_active_hermes_home = lambda: hermes_home
|
||
# Simulate hermes-agent being available so we reach the provider logic
|
||
# (without this, _status_from_runtime short-circuits to agent_unavailable)
|
||
_ob._HERMES_FOUND = True
|
||
try:
|
||
cfg = {"model": {"provider": provider, "default": model}}
|
||
return _status_from_runtime(cfg, True)
|
||
finally:
|
||
_ob._get_active_hermes_home = orig_home
|
||
_ob._HERMES_FOUND = orig_found
|
||
|
||
def test_copilot_ready_when_api_key_in_auth_json(self, tmp_path):
|
||
"""copilot configured + api_key in auth.json -> provider_ready True."""
|
||
_make_auth_json("copilot", {"api_key": "ghu_abc123"}, tmp_path)
|
||
result = self._call("copilot", "gpt-5.4", tmp_path)
|
||
assert result["provider_configured"] is True
|
||
assert result["provider_ready"] is True
|
||
assert result["setup_state"] == "ready"
|
||
|
||
def test_openai_codex_ready_when_token_in_auth_json(self, tmp_path):
|
||
"""openai-codex configured + access_token -> provider_ready True."""
|
||
_make_auth_json(
|
||
"openai-codex",
|
||
{"access_token": "ey.test", "refresh_token": "ref"},
|
||
tmp_path,
|
||
)
|
||
result = self._call("openai-codex", "codex-mini-latest", tmp_path)
|
||
assert result["provider_configured"] is True
|
||
assert result["provider_ready"] is True
|
||
assert result["setup_state"] == "ready"
|
||
|
||
def test_copilot_not_ready_without_credentials(self, tmp_path):
|
||
"""copilot configured but no credentials -> provider_ready False.
|
||
|
||
We mock hermes_cli.auth to be unavailable so the function falls through
|
||
to the auth.json path. With no auth.json the result must be False.
|
||
"""
|
||
import unittest.mock
|
||
|
||
# Prevent the hermes_cli fast path from finding real credentials
|
||
with unittest.mock.patch(
|
||
"api.onboarding._provider_oauth_authenticated",
|
||
return_value=False,
|
||
):
|
||
result = self._call("copilot", "gpt-5.4", tmp_path)
|
||
|
||
assert result["provider_configured"] is True
|
||
assert result["provider_ready"] is False
|
||
assert result["setup_state"] == "provider_incomplete"
|
||
|
||
def test_oauth_incomplete_note_mentions_hermes_auth(self, tmp_path):
|
||
"""When OAuth provider is incomplete, note should mention hermes auth/model."""
|
||
result = self._call("openai-codex", "codex-mini-latest", tmp_path)
|
||
note = result["provider_note"]
|
||
assert "hermes auth" in note or "hermes model" in note, (
|
||
f"Expected 'hermes auth' or 'hermes model' in note, got: {note!r}"
|
||
)
|
||
|
||
def test_oauth_incomplete_note_does_not_say_api_key(self, tmp_path):
|
||
"""OAuth provider incomplete note must not say 'API key' — that's misleading."""
|
||
result = self._call("copilot", "gpt-5.4", tmp_path)
|
||
note = result["provider_note"]
|
||
assert "API key" not in note, (
|
||
f"Note misleadingly mentions 'API key' for OAuth provider: {note!r}"
|
||
)
|
||
|
||
def test_standard_provider_incomplete_note_still_says_api_key(self, tmp_path):
|
||
"""For a standard API-key provider (openrouter), note should still say API key."""
|
||
# openrouter with no .env
|
||
result = self._call("openrouter", "anthropic/claude-sonnet-4.6", tmp_path)
|
||
assert result["provider_ready"] is False
|
||
note = result["provider_note"]
|
||
assert "API key" in note, (
|
||
f"Expected 'API key' in note for openrouter, got: {note!r}"
|
||
)
|
||
|
||
|
||
# ── 6. API endpoint reflects OAuth-ready state ───────────────────────────────
|
||
|
||
class TestOnboardingStatusApiOAuth:
|
||
"""
|
||
The /api/onboarding/status endpoint should report provider_ready=True
|
||
when an OAuth provider is configured and has valid credentials.
|
||
"""
|
||
|
||
def test_status_endpoint_returns_200(self):
|
||
import urllib.request
|
||
with urllib.request.urlopen(BASE + "/api/onboarding/status", timeout=10) as r:
|
||
assert r.status == 200
|
||
data = json.loads(r.read())
|
||
assert "system" in data
|
||
assert "provider_ready" in data["system"]
|
||
|
||
def test_onboarding_status_has_chat_ready_field(self):
|
||
import urllib.request
|
||
with urllib.request.urlopen(BASE + "/api/onboarding/status", timeout=10) as r:
|
||
data = json.loads(r.read())
|
||
assert "chat_ready" in data["system"]
|
||
|
||
def test_status_setup_state_valid_values(self):
|
||
"""setup_state must be one of the known string values."""
|
||
import urllib.request
|
||
with urllib.request.urlopen(BASE + "/api/onboarding/status", timeout=10) as r:
|
||
data = json.loads(r.read())
|
||
valid = {"ready", "provider_incomplete", "needs_provider", "agent_unavailable"}
|
||
assert data["system"]["setup_state"] in valid, (
|
||
f"Unexpected setup_state: {data['system']['setup_state']!r}"
|
||
)
|
||
|
||
|
||
# ── Control Center: section reset on close ─────────────────────────────────
|
||
|
||
def test_control_center_resets_active_section_on_close():
|
||
"""Closing the control center must reset _settingsSection to 'conversation'."""
|
||
src = open('static/panels.js').read()
|
||
assert '_settingsSection' in src, '_settingsSection state variable missing from panels.js'
|
||
assert "_settingsSection = 'conversation'" in src or "_settingsSection='conversation'" in src, \
|
||
'Control center does not reset section to conversation on close'
|
||
|
||
|
||
def test_control_center_tab_highlight_on_open():
|
||
"""Opening the control center must use settings-tabs for section navigation."""
|
||
css = open('static/style.css').read()
|
||
assert 'settings-tabs' in css, 'settings-tabs CSS class for control center tabs missing from style.css'
|