|
| 1 | +""" |
| 2 | +Regression tests for the workspace panel persisting across page reload on |
| 3 | +empty-session and no-session boot paths. |
| 4 | +
|
| 5 | +Two boot paths previously dropped the workspace panel even when the user had |
| 6 | +explicitly opened it before reloading: |
| 7 | +
|
| 8 | + 1. Ephemeral-session guard added in #1182: when the restored session has |
| 9 | + 0 messages, boot clears localStorage and shows the empty state. This |
| 10 | + path was calling ``syncWorkspacePanelState()`` without first restoring |
| 11 | + ``_workspacePanelMode`` from localStorage. |
| 12 | + 2. No-saved-session path: a fresh page load with no localStorage session |
| 13 | + also went straight to ``syncWorkspacePanelState()`` without restoring |
| 14 | + the panel preference. |
| 15 | +
|
| 16 | +Both paths force-closed the panel because ``syncWorkspacePanelState()`` |
| 17 | +unconditionally set ``_workspacePanelMode='closed'`` whenever ``S.session`` |
| 18 | +was null — even when the user's preference was 'open'. |
| 19 | +
|
| 20 | +Fix verified by these tests: |
| 21 | +
|
| 22 | + - ``syncWorkspacePanelState`` checks ``_workspacePanelMode==='preview'`` |
| 23 | + BEFORE force-closing, so 'browse' mode is preserved without a session. |
| 24 | + - Both boot paths read the panel pref from localStorage and set |
| 25 | + ``_workspacePanelMode='browse'`` before calling sync. |
| 26 | + - ``canBrowse`` and ``openWorkspacePanel()`` include |
| 27 | + ``S._profileDefaultWorkspace`` so the toggle stays enabled. |
| 28 | +""" |
| 29 | +import pathlib |
| 30 | + |
| 31 | +REPO = pathlib.Path(__file__).parent.parent |
| 32 | +BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8") |
| 33 | + |
| 34 | + |
| 35 | +# ── 1. syncWorkspacePanelState preserves browse mode without a session ────── |
| 36 | + |
| 37 | + |
| 38 | +class TestSyncStateNoSession: |
| 39 | + def test_preview_mode_without_session_force_closes(self): |
| 40 | + """A 'preview' panel needs file content from a session — close it |
| 41 | + when there's no session.""" |
| 42 | + idx = BOOT_JS.find("function syncWorkspacePanelState()") |
| 43 | + body = BOOT_JS[idx:idx + 800] |
| 44 | + assert "_workspacePanelMode==='preview'" in body, ( |
| 45 | + "syncWorkspacePanelState must check _workspacePanelMode==='preview' " |
| 46 | + "before force-closing on no-session boot" |
| 47 | + ) |
| 48 | + assert "_setWorkspacePanelMode('closed')" in body, ( |
| 49 | + "syncWorkspacePanelState still must close 'preview' mode without a session" |
| 50 | + ) |
| 51 | + |
| 52 | + def test_browse_mode_calls_sync_ui_instead_of_force_close(self): |
| 53 | + """For 'browse' mode without a session, syncWorkspacePanelUI() should |
| 54 | + run so the panel renders its 'no workspace' or default-workspace state |
| 55 | + rather than being force-closed.""" |
| 56 | + idx = BOOT_JS.find("function syncWorkspacePanelState()") |
| 57 | + body = BOOT_JS[idx:idx + 800] |
| 58 | + # The else branch (browse / closed mode without session) calls UI sync |
| 59 | + assert "syncWorkspacePanelUI()" in body, ( |
| 60 | + "syncWorkspacePanelState must call syncWorkspacePanelUI() in the " |
| 61 | + "no-session, non-preview branch so 'browse' mode is preserved" |
| 62 | + ) |
| 63 | + |
| 64 | + |
| 65 | +# ── 2. Both boot paths restore panelPref before sync ──────────────────────── |
| 66 | + |
| 67 | + |
| 68 | +class TestBootPathsRestorePanelPref: |
| 69 | + PREF_PATTERN = "hermes-webui-workspace-panel-pref" |
| 70 | + |
| 71 | + def test_ephemeral_path_restores_panel_pref(self): |
| 72 | + """The empty-session guard (#1182) must read panelPref before |
| 73 | + calling syncWorkspacePanelState().""" |
| 74 | + # Find the ephemeral guard — it's marked by message_count===0 check |
| 75 | + eph_idx = BOOT_JS.find("(S.session.message_count||0) === 0") |
| 76 | + assert eph_idx > 0, "Empty-session guard not found in boot IIFE" |
| 77 | + # The next syncWorkspacePanelState() call after this point is in the ephemeral path |
| 78 | + sync_idx = BOOT_JS.find("syncWorkspacePanelState()", eph_idx) |
| 79 | + assert sync_idx > 0, "syncWorkspacePanelState call not found in ephemeral path" |
| 80 | + # panelPref must be read between the guard and the sync call |
| 81 | + block = BOOT_JS[eph_idx:sync_idx] |
| 82 | + assert self.PREF_PATTERN in block, ( |
| 83 | + "Ephemeral-session boot path must read 'hermes-webui-workspace-panel-pref' " |
| 84 | + "from localStorage before calling syncWorkspacePanelState()" |
| 85 | + ) |
| 86 | + assert "_workspacePanelMode='browse'" in block or "_workspacePanelMode = 'browse'" in block, ( |
| 87 | + "Ephemeral-session path must set _workspacePanelMode='browse' " |
| 88 | + "when the pref is 'open'" |
| 89 | + ) |
| 90 | + |
| 91 | + def test_no_session_path_restores_panel_pref(self): |
| 92 | + """The fresh-load (no localStorage session) path must read panelPref |
| 93 | + before calling syncWorkspacePanelState().""" |
| 94 | + # Find the comment marker that precedes the no-session path |
| 95 | + marker = "no saved session" |
| 96 | + m_idx = BOOT_JS.find(marker) |
| 97 | + assert m_idx > 0, "no-saved-session path not found" |
| 98 | + # syncWorkspacePanelState should appear shortly after |
| 99 | + sync_idx = BOOT_JS.find("syncWorkspacePanelState()", m_idx) |
| 100 | + assert sync_idx > 0, "syncWorkspacePanelState() not found after no-saved-session marker" |
| 101 | + block = BOOT_JS[m_idx:sync_idx] |
| 102 | + assert self.PREF_PATTERN in block, ( |
| 103 | + "No-saved-session boot path must read 'hermes-webui-workspace-panel-pref' " |
| 104 | + "before calling syncWorkspacePanelState()" |
| 105 | + ) |
| 106 | + assert "_workspacePanelMode='browse'" in block or "_workspacePanelMode = 'browse'" in block, ( |
| 107 | + "No-saved-session path must set _workspacePanelMode='browse' " |
| 108 | + "when the pref is 'open'" |
| 109 | + ) |
| 110 | + |
| 111 | + |
| 112 | +# ── 3. Toggle button stays enabled when profile default workspace exists ──── |
| 113 | + |
| 114 | + |
| 115 | +class TestToggleStaysEnabledWithProfileWorkspace: |
| 116 | + def test_can_browse_includes_profile_default_workspace(self): |
| 117 | + """The toggle button's enabled state (canBrowse) must be true when |
| 118 | + S._profileDefaultWorkspace is set, even with no active session.""" |
| 119 | + idx = BOOT_JS.find("const canBrowse=") |
| 120 | + assert idx > 0, "canBrowse declaration not found in syncWorkspacePanelUI" |
| 121 | + line = BOOT_JS[idx:idx + 200].split("\n", 1)[0] |
| 122 | + assert "_profileDefaultWorkspace" in line, ( |
| 123 | + "canBrowse must include S._profileDefaultWorkspace so the toggle " |
| 124 | + "button stays enabled when a profile workspace is configured" |
| 125 | + ) |
| 126 | + |
| 127 | + def test_open_workspace_panel_allows_browse_with_profile_workspace(self): |
| 128 | + """openWorkspacePanel('browse') must not return early when |
| 129 | + S._profileDefaultWorkspace is set, otherwise clicking the toggle |
| 130 | + won't open the panel even though canBrowse said it should.""" |
| 131 | + idx = BOOT_JS.find("function openWorkspacePanel(") |
| 132 | + body = BOOT_JS[idx:idx + 600] |
| 133 | + # The early-return guard should include the profile-workspace check |
| 134 | + assert "_profileDefaultWorkspace" in body, ( |
| 135 | + "openWorkspacePanel must include S._profileDefaultWorkspace in its " |
| 136 | + "early-return guard so users can open the panel via the toggle " |
| 137 | + "button when a profile workspace is configured" |
| 138 | + ) |
0 commit comments