diff --git a/e2e/test_admin_integrations.py b/e2e/test_admin_integrations.py index 1617e2bd70..b4657cda0b 100644 --- a/e2e/test_admin_integrations.py +++ b/e2e/test_admin_integrations.py @@ -374,6 +374,10 @@ def retry(route): button.click() expect(button).to_be_disabled() assert retries == ["POST"] + page.wait_for_function( + "id => state.startup?.startup?.integrations[id]?.state === 'starting'", + arg=integration, + ) progress.update(state="ready", changed=True) expect(button).to_have_text("Disconnect") expect(button).to_be_enabled() @@ -417,9 +421,7 @@ def test_startup_updates_before_opening_integrations( page.goto(f"{admin_base_url}/admin/integrations") button = page.locator("#openClaudeIntegration") expect(button).to_have_text("Disconnect") - expect(page.locator("#claudeIntegrationMessage")).to_have_text( - "Settings updated. Reload VS Code." - ) + expect(page.locator("#claudeIntegrationMessage")).to_be_hidden() saved = json.loads(admin_client_files.read_text()) assert saved["editor.fontSize"] == 15 assert any( diff --git a/e2e/test_integration_notices.py b/e2e/test_integration_notices.py new file mode 100644 index 0000000000..9525c6dc2c --- /dev/null +++ b/e2e/test_integration_notices.py @@ -0,0 +1,151 @@ +import pytest +from playwright.sync_api import expect + + +@pytest.fixture( + params=[ + ("claude-vscode", "openClaudeIntegration", "claudeIntegrationMessage"), + ("codex", "openCodexIntegration", "codexIntegrationMessage"), + ("jetbrains-acp", "openJetBrainsIntegration", "jetBrainsIntegrationMessage"), + ( + "claude-desktop", + "openClaudeDesktopIntegration", + "claudeDesktopIntegrationMessage", + ), + ] +) +def notice(page, admin_base_url, request): + integration, button_id, message_id = request.param + progress = {"state": "ready", "changed": True, "message": None} + server = {"instance_id": "first"} + + def startup(route): + response = route.fetch() + payload = response.json() + payload["instance_id"] = server["instance_id"] + payload["startup"]["integrations"][integration] = dict(progress) + route.fulfill(response=response, json=payload) + + def status(route): + route.fulfill(json={"connected": True, "paths": None, "update": dict(progress)}) + + page.route("**/admin/api/status", startup) + page.route(f"**/admin/api/integrations/{integration}", status) + page.goto(f"{admin_base_url}/admin/integrations") + button = page.locator(f"#{button_id}") + message = page.locator(f"#{message_id}") + expect(button).to_have_text("Disconnect") + + def observe(phase, changed=False, instance="first"): + progress.update( + state=phase, + changed=changed, + message="Update failed" if phase == "failed" else None, + ) + server["instance_id"] = instance + page.evaluate("() => refreshStartup()") + page.wait_for_function( + "([id, phase, changed, instance]) => state.startup?.instance_id === instance && state.startup.startup.integrations[id].state === phase && state.startup.startup.integrations[id].changed === changed", + arg=[integration, phase, changed, instance], + ) + + return integration, button, message, observe + + +def test_notices_require_a_live_changed_completion(page, notice): + _, button, message, observe = notice + expect(message).to_be_hidden() + observe("starting") + expect(button).to_be_disabled() + observe("ready", changed=True) + expect(message).to_be_visible() + expect(message).not_to_have_class("message-area error") + expect(button).to_be_enabled() + page.get_by_role("button", name="Providers", exact=True).click() + page.get_by_role("button", name="Integrations", exact=True).click() + expect(button).to_be_enabled() + expect(message).to_be_hidden() + observe("ready", changed=True) + expect(message).to_be_hidden() + page.reload() + expect(button).to_be_enabled() + expect(message).to_be_hidden() + observe("starting") + observe("ready", changed=False) + expect(button).to_be_enabled() + expect(message).to_be_hidden() + observe("starting") + observe("failed") + expect(message).to_have_text("Update failed") + observe("starting") + observe("ready", changed=True) + expect(message).to_be_visible() + expect(message).not_to_have_text("Update failed") + + +@pytest.mark.parametrize("revisit_while_busy", [False, True]) +def test_busy_status_recovery_preserves_notice_without_replaying_it( + page, notice, revisit_while_busy +): + integration, button, message, observe = notice + observe("starting") + pending = [] + + def hold_status(route): + pending.append(route) + page.evaluate("window.integrationStatusIntercepted = true") + + page.route(f"**/admin/api/integrations/{integration}", hold_status) + page.get_by_role("button", name="Providers", exact=True).click() + with page.expect_request(f"**/admin/api/integrations/{integration}"): + page.get_by_role("button", name="Integrations", exact=True).click() + page.wait_for_function("window.integrationStatusIntercepted === true") + observe("ready", changed=True) + expect(message).to_be_visible() + if revisit_while_busy: + page.get_by_role("button", name="Providers", exact=True).click() + page.get_by_role("button", name="Integrations", exact=True).click() + expect(message).to_be_hidden() + # Release a stale read after the completion poll. The subsequent recovery GET + # must restore the button without erasing or replaying the completion notice. + page.unroute(f"**/admin/api/integrations/{integration}") + page.route( + f"**/admin/api/integrations/{integration}", + lambda route: route.fulfill( + json={ + "connected": True, + "paths": None, + "update": {"state": "ready", "changed": True}, + } + ), + ) + assert len(pending) == 1 + pending[0].fulfill( + json={ + "connected": None, + "paths": None, + "update": {"state": "starting", "changed": False}, + } + ) + expect(button).to_be_enabled() + if revisit_while_busy: + expect(message).to_be_hidden() + else: + expect(message).to_be_visible() + page.get_by_role("button", name="Providers", exact=True).click() + page.get_by_role("button", name="Integrations", exact=True).click() + expect(message).to_be_hidden() + + +def test_new_server_and_off_page_completions_stay_quiet(page, notice): + _, button, message, observe = notice + observe("starting") + observe("ready", changed=True, instance="second") + expect(button).to_be_enabled() + expect(message).to_be_hidden() + observe("starting", instance="second") + page.get_by_role("button", name="Providers", exact=True).click() + observe("ready", changed=True, instance="second") + page.get_by_role("button", name="Integrations", exact=True).click() + expect(button).to_be_enabled() + expect(message).to_be_hidden() diff --git a/pyproject.toml b/pyproject.toml index eb825ca613..fc07d7c2e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.52" +version = "6.2.53" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" requires-python = ">=3.14.0" diff --git a/src/free_claude_code/api/admin_static/admin.js b/src/free_claude_code/api/admin_static/admin.js index 7acf3d4ee7..71f33d238d 100644 --- a/src/free_claude_code/api/admin_static/admin.js +++ b/src/free_claude_code/api/admin_static/admin.js @@ -165,7 +165,10 @@ async function refreshStartup() { )) return; state.startup = result; renderStartup(); - refreshIntegrationUpdates(previous?.startup?.integrations, current.integrations); + refreshIntegrationUpdates( + previous?.instance_id === result.instance_id ? previous.startup.integrations : null, + current.integrations, + ); const changed = !previous || previous.instance_id !== result.instance_id || JSON.stringify(previous.startup) !== JSON.stringify(current); if (changed) void hydrateModelOptions(); @@ -1498,16 +1501,19 @@ function integrationUpdating(integration, id) { function refreshIntegrationUpdates(previous, current) { if (state.activeView !== "integrations") return; - for (const [id, integration, refresh] of [ - ["claude-vscode", claudeIntegration, refreshClaudeIntegration], - ["jetbrains-acp", jetBrainsIntegration, refreshJetBrainsIntegration], - ["codex", codexIntegration, refreshCodexIntegration], - ["claude-desktop", claudeDesktopIntegration, refreshClaudeDesktopIntegration], + for (const [id, integration, refresh, messageId, message] of [ + ["claude-vscode", claudeIntegration, refreshClaudeIntegration, "claudeIntegrationMessage", "Settings updated. Reload VS Code."], + ["jetbrains-acp", jetBrainsIntegration, refreshJetBrainsIntegration, "jetBrainsIntegrationMessage", "Settings updated. Reopen JetBrains and start a new chat."], + ["codex", codexIntegration, refreshCodexIntegration, "codexIntegrationMessage", "Settings updated. Restart Codex."], + ["claude-desktop", claudeDesktopIntegration, refreshClaudeDesktopIntegration, "claudeDesktopIntegrationMessage", "Settings updated. Reopen Claude Desktop."], ]) { const phase = current?.[id]?.state; if (phase && phase !== "starting" && ( previous?.[id]?.state === "starting" || integration.update?.state === "starting" - )) void refresh(); + )) void refresh(false, { background: true }); + if (previous?.[id]?.state === "starting" && phase === "ready" && current[id].changed === true) { + integrationMessage(messageId, message); + } } } @@ -1539,11 +1545,11 @@ function renderClaudeIntegration() { } } -async function refreshClaudeIntegration(retry = false) { +async function refreshClaudeIntegration(retry = false, { background = false } = {}) { + if (!background) integrationMessage("claudeIntegrationMessage", ""); if (claudeIntegration.busy) return; claudeIntegration.busy = true; renderClaudeIntegration(); - integrationMessage("claudeIntegrationMessage", ""); try { if (retry) await api(`${claudeIntegrationPath}/refresh`, { method: "POST" }); const result = await api(claudeIntegrationPath); @@ -1551,7 +1557,7 @@ async function refreshClaudeIntegration(retry = false) { claudeIntegration.paths = result.paths; claudeIntegration.update = result.update; if (result.update?.state === "failed") integrationMessage("claudeIntegrationMessage", result.update.message, true); - else if (result.update?.changed) integrationMessage("claudeIntegrationMessage", "Settings updated. Reload VS Code."); + else if (byId("claudeIntegrationMessage").classList.contains("error")) integrationMessage("claudeIntegrationMessage", ""); } catch (error) { claudeIntegration.connected = null; claudeIntegration.update = null; @@ -1640,11 +1646,11 @@ function renderCodexIntegration() { } } -async function refreshCodexIntegration(retry = false) { +async function refreshCodexIntegration(retry = false, { background = false } = {}) { + if (!background) integrationMessage("codexIntegrationMessage", ""); if (codexIntegration.busy) return; codexIntegration.busy = true; renderCodexIntegration(); - integrationMessage("codexIntegrationMessage", ""); try { if (retry) await api(`${codexIntegrationPath}/refresh`, { method: "POST" }); const result = await api(codexIntegrationPath); @@ -1652,7 +1658,7 @@ async function refreshCodexIntegration(retry = false) { codexIntegration.paths = result.paths; codexIntegration.update = result.update; if (result.update?.state === "failed") integrationMessage("codexIntegrationMessage", result.update.message, true); - else if (result.update?.changed) integrationMessage("codexIntegrationMessage", "Settings updated. Restart Codex."); + else if (byId("codexIntegrationMessage").classList.contains("error")) integrationMessage("codexIntegrationMessage", ""); } catch (error) { codexIntegration.connected = null; codexIntegration.update = null; @@ -1737,11 +1743,11 @@ function renderJetBrainsIntegration() { } } -async function refreshJetBrainsIntegration(retry = false) { +async function refreshJetBrainsIntegration(retry = false, { background = false } = {}) { + if (!background) integrationMessage("jetBrainsIntegrationMessage", ""); if (jetBrainsIntegration.busy) return; jetBrainsIntegration.busy = true; renderJetBrainsIntegration(); - integrationMessage("jetBrainsIntegrationMessage", ""); try { if (retry) await api(`${jetBrainsIntegrationPath}/refresh`, { method: "POST" }); const result = await api(jetBrainsIntegrationPath); @@ -1749,7 +1755,7 @@ async function refreshJetBrainsIntegration(retry = false) { jetBrainsIntegration.paths = result.paths; jetBrainsIntegration.update = result.update; if (result.update?.state === "failed") integrationMessage("jetBrainsIntegrationMessage", result.update.message, true); - else if (result.update?.changed) integrationMessage("jetBrainsIntegrationMessage", "Settings updated. Reopen JetBrains and start a new chat."); + else if (byId("jetBrainsIntegrationMessage").classList.contains("error")) integrationMessage("jetBrainsIntegrationMessage", ""); } catch (error) { jetBrainsIntegration.connected = null; jetBrainsIntegration.update = null; @@ -1836,11 +1842,11 @@ function renderClaudeDesktopIntegration() { } } -async function refreshClaudeDesktopIntegration(retry = false) { +async function refreshClaudeDesktopIntegration(retry = false, { background = false } = {}) { + if (!background) integrationMessage("claudeDesktopIntegrationMessage", ""); if (claudeDesktopIntegration.busy) return; claudeDesktopIntegration.busy = true; renderClaudeDesktopIntegration(); - integrationMessage("claudeDesktopIntegrationMessage", ""); try { if (retry) await api(`${claudeDesktopIntegrationPath}/refresh`, { method: "POST" }); const result = await api(claudeDesktopIntegrationPath); @@ -1849,7 +1855,7 @@ async function refreshClaudeDesktopIntegration(retry = false) { claudeDesktopIntegration.paths = result.paths; claudeDesktopIntegration.update = result.update; if (result.update?.state === "failed") integrationMessage("claudeDesktopIntegrationMessage", result.update.message, true); - else if (result.update?.changed) integrationMessage("claudeDesktopIntegrationMessage", "Settings updated. Reopen Claude Desktop."); + else if (byId("claudeDesktopIntegrationMessage").classList.contains("error")) integrationMessage("claudeDesktopIntegrationMessage", ""); } catch (error) { claudeDesktopIntegration.connected = null; claudeDesktopIntegration.disconnectPending = false; diff --git a/uv.lock b/uv.lock index ead56c5a25..d6b7614d91 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.52" +version = "6.2.53" source = { editable = "." } dependencies = [ { name = "aiohttp" },