* fix: suppress console windows for git subprocess calls on Windows On Windows, every git.exe spawned by the WebUI (update checks, version detection, workspace git info, rollback inspection, worktree management) creates a visible console window. When Windows Terminal is the default terminal, each short-lived git process opens a new tab. Combined with the 60-second version-skew monitor polling /api/settings and the boot-time update check, this produces a flood of flashing terminal tabs. Root cause: subprocess.run() calls in api/updates.py, api/rollback.py, api/worktrees.py, and api/workspace.py did not pass creationflags=CREATE_NO_WINDOW on Windows. api/workspace_git.py already had the fix (#5692); these four files were missed. Backend fix: - Add _windows_hide_flags() helper to each affected module (mirrors the existing pattern in api/workspace_git.py) - Pass creationflags=_windows_hide_flags() to all git subprocess.run() calls (10 call sites across 4 files) Frontend fix (prevent timeout->retry cascades): - Increase update-check timeout from 30s/60s to 300s (5 min) - Increase /api/settings fetches from 30s to 60s - Increase /api/git-info fetch from 30s to 60s The backend git fetch to GitHub can take 15s per repo; with both WebUI and Agent repos checked sequentially plus other git calls, the total easily exceeds the old 30s default frontend timeout, triggering the api() wrapper auto-retry (3 attempts) and amplifying the git process flood. * [verified] fix: address Windows git subprocess review --------- Co-authored-by: ya1yi2fa3 <ya1yi2fa3@users.noreply.github.com> Co-authored-by: n <a@n>
274 lines
13 KiB
Python
274 lines
13 KiB
Python
"""Tests for #804 — blank new-chat page loses default workspace binding
|
|
|
|
Fixes:
|
|
- syncWorkspaceDisplays() uses S._profileDefaultWorkspace as fallback when no session
|
|
- composerChip.disabled uses hasWorkspace (not hasSession) so chip is enabled on blank page
|
|
- boot.js reads default_workspace from /api/settings and sets S._profileDefaultWorkspace
|
|
- promptNewFile/promptNewFolder auto-create a session bound to default workspace
|
|
"""
|
|
import pathlib
|
|
import re
|
|
|
|
REPO = pathlib.Path(__file__).parent.parent
|
|
|
|
|
|
def read(rel):
|
|
return (REPO / rel).read_text(encoding='utf-8')
|
|
|
|
|
|
class TestSyncWorkspaceDisplaysFallback:
|
|
"""syncWorkspaceDisplays must show default workspace when no session."""
|
|
|
|
def test_uses_profile_default_workspace_as_fallback(self):
|
|
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 "
|
|
"when no active session is present"
|
|
)
|
|
|
|
def test_has_workspace_not_has_session_for_chip_disable(self):
|
|
src = read('static/panels.js')
|
|
m = re.search(r'function syncWorkspaceDisplays\(\)\{.*?\n\}', src, re.DOTALL)
|
|
assert m
|
|
fn = m.group(0)
|
|
# composerChip.disabled must use hasWorkspace, not hasSession
|
|
assert 'composerChip.disabled=!hasWorkspace' in fn or \
|
|
'composerChip.disabled = !hasWorkspace' in fn, (
|
|
"composerChip.disabled must use !hasWorkspace (not !hasSession) so the chip "
|
|
"is enabled on the blank new-chat page when a default workspace is configured"
|
|
)
|
|
assert 'composerChip.disabled=!hasSession' not in fn, (
|
|
"composerChip.disabled must not use !hasSession — this was the regression"
|
|
)
|
|
|
|
|
|
class TestBootJsProfileDefaultWorkspace:
|
|
"""boot.js must read default_workspace from /api/settings into S._profileDefaultWorkspace."""
|
|
|
|
def test_boot_reads_default_workspace_from_settings(self):
|
|
src = read('static/boot.js')
|
|
assert '_profileDefaultWorkspace' in src, (
|
|
"boot.js must set S._profileDefaultWorkspace from the /api/settings "
|
|
"default_workspace field so it is available before any session is created"
|
|
)
|
|
|
|
def test_boot_sets_profile_default_workspace_in_settings_block(self):
|
|
"""The settings block (lines ~758-800 in boot.js) must set
|
|
S._profileDefaultWorkspace from the /api/settings response."""
|
|
src = read('static/boot.js')
|
|
# Find the settings fetch and the _profileDefaultWorkspace ASSIGNMENT
|
|
# (the if(s.default_workspace) line, not usages elsewhere in the file)
|
|
settings_match = re.search(
|
|
r"await api\('/api/settings'(?:,\{[^)]*\})?\)",
|
|
src,
|
|
)
|
|
assert settings_match, "await api('/api/settings', optionalOptions) not found in boot.js"
|
|
settings_idx = settings_match.start()
|
|
# Find the assignment specifically — it uses 's.default_workspace'
|
|
ws_assign_idx = src.find('S._profileDefaultWorkspace=s.default_workspace')
|
|
assert ws_assign_idx != -1, "S._profileDefaultWorkspace assignment not found in boot.js"
|
|
# The assignment must be in the same settings-fetch block (within a few hundred chars)
|
|
assert abs(ws_assign_idx - settings_idx) < 1000, (
|
|
"S._profileDefaultWorkspace must be set in the same settings-fetch block"
|
|
)
|
|
|
|
def test_boot_sets_profile_default_workspace_from_profile_active(self):
|
|
"""Profile active bootstrap must override settings with p.default_workspace (#5169)."""
|
|
src = read('static/boot.js')
|
|
active_idx = src.find("api('/api/profile/active'")
|
|
assert active_idx != -1, "/api/profile/active fetch not found in boot.js"
|
|
block = src[active_idx:active_idx + 1200]
|
|
assert 'p.default_workspace' in block, (
|
|
"boot.js must read default_workspace from /api/profile/active response"
|
|
)
|
|
assert '_profileDefaultWorkspace' in block, (
|
|
"boot.js must assign S._profileDefaultWorkspace from profile active bootstrap"
|
|
)
|
|
assert re.search(
|
|
r"if\s*\(\s*p\.default_workspace\s*\)\s*S\._profileDefaultWorkspace\s*=\s*p\.default_workspace",
|
|
block,
|
|
), (
|
|
"boot.js must set S._profileDefaultWorkspace when p.default_workspace is present"
|
|
)
|
|
|
|
|
|
class TestPromptNewFileNoSession:
|
|
"""promptNewFile/promptNewFolder must auto-create a session on blank page."""
|
|
|
|
def test_prompt_new_file_auto_creates_session(self):
|
|
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)
|
|
# Must have auto-create path (not just early return when no session)
|
|
assert '_profileDefaultWorkspace' in fn, (
|
|
"promptNewFile must read S._profileDefaultWorkspace to auto-create "
|
|
"a session when called on the blank new-chat page"
|
|
)
|
|
assert 'session/new' in fn, (
|
|
"promptNewFile must call /api/session/new to create a session "
|
|
"bound to the default workspace when S.session is null"
|
|
)
|
|
|
|
def test_prompt_new_folder_auto_creates_session(self):
|
|
src = read('static/ui.js')
|
|
m = re.search(r'async function promptNewFolder\([^)]*\)\{.*?\n\}', src, re.DOTALL)
|
|
assert m, "promptNewFolder not found"
|
|
fn = m.group(0)
|
|
assert '_profileDefaultWorkspace' in fn, (
|
|
"promptNewFolder must read S._profileDefaultWorkspace for auto-create path"
|
|
)
|
|
assert 'session/new' in fn, (
|
|
"promptNewFolder must call /api/session/new to create session on blank page"
|
|
)
|
|
|
|
def test_prompt_new_file_still_returns_early_without_default(self):
|
|
"""If no default workspace, the function should return early (not crash)."""
|
|
src = read('static/ui.js')
|
|
m = re.search(r'async function promptNewFile\([^)]*\)\{.*?\n\}', src, re.DOTALL)
|
|
assert m
|
|
fn = m.group(0)
|
|
# Must have a guard for empty workspace
|
|
assert "if(!ws) return" in fn or "if(!ws)return" in fn, (
|
|
"promptNewFile must return early if no default workspace is configured"
|
|
)
|
|
|
|
|
|
class TestWorkspaceSwitcherBlankPage:
|
|
"""Opus review Q6: workspace switcher dropdown must not silently fail on blank page."""
|
|
|
|
def test_switch_to_workspace_auto_creates_session(self):
|
|
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 '_profileDefaultWorkspace' in fn or 'session/new' in fn, (
|
|
"switchToWorkspace must auto-create session on blank page (Opus Q6 fix)"
|
|
)
|
|
assert 'session/new' in fn, (
|
|
"switchToWorkspace must call /api/session/new when S.session is null"
|
|
)
|
|
|
|
def test_switch_to_workspace_keeps_busy_guard_after_blank_page_create(self):
|
|
src = read('static/panels.js')
|
|
start = src.find('async function switchToWorkspace(')
|
|
assert start != -1, "switchToWorkspace not found"
|
|
fn = src[start:src.find('async function toggleWorktreePanel', start)]
|
|
assert "t('workspace_busy_switch')" in fn, (
|
|
"switchToWorkspace must keep the busy-session workspace switch toast"
|
|
)
|
|
blank_create = fn.index("api('/api/session/new'")
|
|
busy_guard = fn.index('if(S.busy)')
|
|
update_call = fn.index("api('/api/session/update'")
|
|
assert blank_create < busy_guard < update_call, (
|
|
"switchToWorkspace must auto-create blank-page sessions before the busy guard, "
|
|
"then return before workspace update while busy"
|
|
)
|
|
|
|
def test_prompt_workspace_path_auto_creates_session(self):
|
|
src = read('static/panels.js')
|
|
m = re.search(r'async function promptWorkspacePath\(\)\{.*?\n\}', src, re.DOTALL)
|
|
assert m, "promptWorkspacePath not found"
|
|
fn = m.group(0)
|
|
assert 'session/new' in fn, (
|
|
"promptWorkspacePath must call /api/session/new when S.session is null"
|
|
)
|
|
|
|
def test_sync_workspace_displays_dropdown_close_uses_has_workspace(self):
|
|
src = read('static/panels.js')
|
|
m = re.search(r'function syncWorkspaceDisplays\(\)\{.*?\n\}', src, re.DOTALL)
|
|
assert m, "syncWorkspaceDisplays not found"
|
|
fn = m.group(0)
|
|
# Line 555: dropdown force-close must use hasWorkspace, not hasSession
|
|
assert '!hasWorkspace && composerDropdown' in fn or '!hasWorkspace&&composerDropdown' in fn, (
|
|
"syncWorkspaceDisplays must use !hasWorkspace (not !hasSession) to decide "
|
|
"whether to force-close the dropdown (Opus Q6 fix)"
|
|
)
|
|
assert '!hasSession && composerDropdown' not in fn, (
|
|
"Regression guard: !hasSession for dropdown close must be removed"
|
|
)
|
|
|
|
|
|
class TestWorkspaceDropdownBlankPageCurrentWs:
|
|
"""Blank-page workspace dropdown must highlight profile default (#5169)."""
|
|
|
|
def test_render_workspace_dropdown_uses_profile_default_on_blank_page(self):
|
|
src = read('static/panels.js')
|
|
assert re.search(
|
|
r"renderWorkspaceDropdownInto\([^,]+,\s*[^,]+,\s*"
|
|
r"S\.session\?\.workspace\|\|S\._profileDefaultWorkspace\|\|data\.last\|\|''\)",
|
|
src,
|
|
), (
|
|
"renderWorkspaceDropdownInto must use session, profile default, or data.last "
|
|
"as current workspace on blank page"
|
|
)
|
|
|
|
|
|
class TestNewChatOnWorkspaceSwitchOptIn:
|
|
"""#5473 opt-in: switching to a DIFFERENT workspace starts a new chat instead
|
|
of mutating the current session in place. Default OFF preserves shipped behavior."""
|
|
|
|
def test_setting_registered_default_off(self):
|
|
import api.config as c
|
|
assert c._SETTINGS_DEFAULTS.get('new_chat_on_workspace_switch') is False, (
|
|
"new_chat_on_workspace_switch must default to False (shipped in-place behavior)"
|
|
)
|
|
assert 'new_chat_on_workspace_switch' in c._SETTINGS_BOOL_KEYS, (
|
|
"new_chat_on_workspace_switch must be a recognized boolean setting key"
|
|
)
|
|
|
|
def test_switch_to_workspace_has_gated_new_chat_branch(self):
|
|
src = read('static/panels.js')
|
|
start = src.find('async function switchToWorkspace(')
|
|
assert start != -1
|
|
fn = src[start:src.find('async function toggleWorktreePanel', start)]
|
|
# The new-chat branch must be gated on the opt-in flag AND a different workspace.
|
|
assert 'window._newChatOnWorkspaceSwitch===true' in fn, (
|
|
"the new-chat branch must be gated on the default-off opt-in flag"
|
|
)
|
|
assert 'path!==S.session.workspace' in fn, (
|
|
"the new-chat branch must only fire when the target workspace DIFFERS "
|
|
"(same-workspace selection stays an in-place refresh/no-op)"
|
|
)
|
|
assert 'S.messages.length>0' in fn, (
|
|
"the new-chat branch must only fire when the current conversation has messages"
|
|
)
|
|
assert 'newSession(false)' in fn, (
|
|
"the new-chat branch must call newSession() to start the fresh chat"
|
|
)
|
|
# The branch must run BEFORE the in-place /api/session/update mutation.
|
|
newchat_idx = fn.index('window._newChatOnWorkspaceSwitch===true')
|
|
update_idx = fn.index("api('/api/session/update'")
|
|
assert newchat_idx < update_idx, (
|
|
"the opt-in new-chat branch must short-circuit before the in-place workspace update"
|
|
)
|
|
|
|
def test_boot_and_panels_wire_the_flag(self):
|
|
boot = read('static/boot.js')
|
|
panels = read('static/panels.js')
|
|
assert 'window._newChatOnWorkspaceSwitch=!!s.new_chat_on_workspace_switch' in boot, (
|
|
"boot.js must set window._newChatOnWorkspaceSwitch from the loaded settings"
|
|
)
|
|
assert 'settingsNewChatOnWorkspaceSwitch' in panels, (
|
|
"panels.js must wire the settings checkbox (load + payload)"
|
|
)
|
|
assert 'payload.new_chat_on_workspace_switch' in panels, (
|
|
"panels.js must include new_chat_on_workspace_switch in the autosave payload"
|
|
)
|
|
|
|
def test_settings_checkbox_and_i18n_present(self):
|
|
html = read('static/index.html')
|
|
assert 'id="settingsNewChatOnWorkspaceSwitch"' in html, (
|
|
"the Settings checkbox for the opt-in must exist"
|
|
)
|
|
i18n = read('static/i18n.js')
|
|
for key in (
|
|
'settings_label_new_chat_on_workspace_switch',
|
|
'settings_desc_new_chat_on_workspace_switch',
|
|
'workspace_switched_new_chat',
|
|
):
|
|
assert key in i18n, f"i18n key {key} must be defined"
|