Files
hermes-webui/tests/test_profile_default_workspace_823.py
T
claw-ioandnesquena-hermes 159298d319 fix(profiles): use profile-scoped resolver for /api/profile/active default_workspace (Codex gate #5169)
Codex flagged that get_last_workspace() falls back to the GLOBAL last_workspace.txt
before the profile config.yaml — so a named profile without its own
last_workspace.txt would leak the global path, re-introducing the #5169 bug.
Add get_profile_default_workspace() (profile last_workspace.txt -> config.yaml ->
terminal.cwd -> default, NO global fallback) and call it from /api/profile/active.
Adds a regression test: global last_workspace.txt present + named profile with
only config.yaml workspace must return the profile path, never the global.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-29 01:45:17 +00:00

163 lines
7.4 KiB
Python

"""Tests for #823 — _profileDefaultWorkspace persists after newSession() (#804 follow-up)
Root cause: newSession() consumed S._profileDefaultWorkspace for the one-shot
profile-switch semantic (setting it to null after the first new session). This
caused the blank-page default workspace display to regress after any session
was created and then deleted.
Fix: introduce S._profileSwitchWorkspace as the dedicated one-shot flag for
profile-switch semantics; S._profileDefaultWorkspace is now persistent.
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
def read(rel):
return (REPO / rel).read_text(encoding='utf-8')
class TestProfileDefaultWorkspacePersistence:
"""_profileDefaultWorkspace must NOT be nulled by newSession()."""
def test_new_session_does_not_null_profile_default_workspace(self):
src = read('static/sessions.js')
m = re.search(r'async function newSession\(.*?\n\}', src, re.DOTALL)
assert m, "newSession not found"
fn = m.group(0)
# The old consume pattern must be gone
assert '_profileDefaultWorkspace=null' not in fn and \
'_profileDefaultWorkspace = null' not in fn, (
"newSession must NOT null S._profileDefaultWorkspace — it is the persistent "
"boot/settings default used for blank-page display after session delete (#823)"
)
def test_new_session_uses_dedicated_switch_workspace_flag(self):
src = read('static/sessions.js')
m = re.search(r'async function newSession\(.*?\n\}', src, re.DOTALL)
assert m
fn = m.group(0)
assert '_profileSwitchWorkspace' in fn, (
"newSession must read S._profileSwitchWorkspace for the one-shot "
"profile-switch inherit, not S._profileDefaultWorkspace"
)
# It should null the switch flag, not the default
assert '_profileSwitchWorkspace=null' in fn or '_profileSwitchWorkspace = null' in fn, (
"newSession must null S._profileSwitchWorkspace after consuming it"
)
def test_new_session_still_inherits_default_workspace(self):
"""newSession must still pass a workspace to /api/session/new,
now via the _profileSwitchWorkspace -> _profileDefaultWorkspace -> current session chain."""
src = read('static/sessions.js')
m = re.search(r'async function newSession\(.*?\n\}', src, re.DOTALL)
assert m
fn = m.group(0)
# inheritWs must be computed and passed to /api/session/new
assert 'inheritWs' in fn or 'inherit' in fn.lower(), (
"newSession must compute an inheritWs from switch/default/current workspace"
)
assert '_profileDefaultWorkspace' in fn, (
"newSession must prefer S._profileDefaultWorkspace before current session workspace"
)
class TestProfileSwitchWorkspaceSetter:
"""panels.js must set _profileSwitchWorkspace on profile switch."""
def test_panels_sets_profile_switch_workspace(self):
src = read('static/panels.js')
# Find the profile-switch workspace block
assert 'S._profileSwitchWorkspace' in src, (
"panels.js must set S._profileSwitchWorkspace during profile switch "
"so newSession() can apply it to the first new session"
)
def test_panels_still_sets_profile_default_workspace(self):
src = read('static/panels.js')
assert 'S._profileDefaultWorkspace = data.default_workspace' in src, (
"panels.js must still set S._profileDefaultWorkspace (persistent default) "
"alongside S._profileSwitchWorkspace"
)
def test_both_set_together_in_same_block(self):
src = read('static/panels.js')
default_pos = src.find('S._profileDefaultWorkspace = data.default_workspace')
switch_pos = src.find('S._profileSwitchWorkspace = data.default_workspace')
assert default_pos != -1, "S._profileDefaultWorkspace setter not found"
assert switch_pos != -1, "S._profileSwitchWorkspace setter not found"
# Both must be set within 200 chars of each other (same block)
assert abs(default_pos - switch_pos) < 300, (
"_profileDefaultWorkspace and _profileSwitchWorkspace must be set "
"together in the same profile-switch workspace block"
)
def test_switch_to_workspace_clears_profile_switch_workspace(self):
"""Opus Q4: when the user manually changes workspace, the pending one-shot
switch flag should be cleared so a subsequent newSession() inherits the
user's explicit choice rather than the stale profile-switch default."""
src = read('static/panels.js')
m = re.search(r'async function switchToWorkspace\(.*?\n\}', src, re.DOTALL)
assert m, "switchToWorkspace not found"
fn = m.group(0)
assert '_profileSwitchWorkspace=null' in fn or '_profileSwitchWorkspace = null' in fn, (
"switchToWorkspace must null S._profileSwitchWorkspace after a manual switch "
"so the next newSession() inherits the user's explicit workspace choice"
)
class TestBlankPageAfterSessionDelete:
"""After all sessions are deleted, blank page must still show default workspace."""
def test_sync_workspace_displays_reads_profile_default(self):
"""syncWorkspaceDisplays relies on S._profileDefaultWorkspace which must
still be set after a session is created and deleted."""
src = read('static/panels.js')
m = re.search(r'function syncWorkspaceDisplays\(\)\{.*?\n\}', src, re.DOTALL)
assert m, "syncWorkspaceDisplays not found"
fn = m.group(0)
assert '_profileDefaultWorkspace' in fn, (
"syncWorkspaceDisplays must read S._profileDefaultWorkspace as fallback"
)
def test_prompt_new_file_reads_profile_default(self):
"""promptNewFile on blank page reads _profileDefaultWorkspace which must
be non-null even after a newSession() + deleteSession() cycle."""
src = read('static/ui.js')
m = re.search(r'async function promptNewFile\([^)]*\)\{.*?\n\}', src, re.DOTALL)
assert m, "promptNewFile not found"
fn = m.group(0)
assert '_profileDefaultWorkspace' in fn, (
"promptNewFile must read S._profileDefaultWorkspace (must persist after newSession)"
)
class TestProfileActiveDefaultWorkspaceApi:
"""GET /api/profile/active must expose profile-scoped default_workspace (#5169)."""
def test_profile_active_includes_default_workspace(self, monkeypatch):
import api.profiles as profiles
import api.routes as routes
expected_ws = "/tmp/my-profile-workspace"
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "work")
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: "/home/.hermes/profiles/work")
monkeypatch.setattr(profiles, "_is_root_profile", lambda _n: False)
monkeypatch.setattr(routes, "get_profile_default_workspace", lambda: expected_ws)
monkeypatch.setattr(
routes,
"j",
lambda _handler, payload, status=200: {"status": status, "payload": payload},
)
from types import SimpleNamespace
from urllib.parse import urlparse
response = routes.handle_get(SimpleNamespace(), urlparse("/api/profile/active"))
assert response["payload"]["default_workspace"] == expected_ws
assert response["payload"]["name"] == "work"