mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-10-18 23:17:19 +00:00
patch: add independent Claude onboarding (#2052)
Some checks are pending
Some checks are pending
## Why
Claude onboarding was tied to the VS Code connection. A missing or
invalid `.claude.json` could make a configured editor appear
disconnected, and background refresh completed onboarding. Users also
had no way to complete onboarding independently.
A pending standalone save could leave the onboarding card incomplete
after successful VS Code Connect.
## How
Add a Claude Code onboarding card and local Admin status/completion
endpoints. A dedicated helper reads status passively and completes
onboarding with an idempotent atomic update that preserves unrelated
values. Keep VS Code status, refresh, and diagnostics scoped to its
editor settings.
Explicit VS Code Connect validates editor settings, completes
onboarding, then saves the connection under the existing owned locks. If
the final save fails, onboarding remains complete and Connect can retry.
Queue refreshes requested during an onboarding save, reconcile once the
save settles, and discard stale status replies.
<!-- greptile_comment -->
<!-- greptile_summary -->
<h2><a
href="https://app.greptile.com/api/retrigger?id=78830265"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=2"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"
align="right"></picture></a>Confidence Score: 5/5</h2>
<!-- greptile-impact-verdict --> No identified issues block merging. The
previous diagnostics failure-handling concern is fully addressed by
removing the wrapper and its workflow loading.
<details open><summary><h3>Summary</h3></summary>
This PR separates Claude Code onboarding from the VS Code connection.
- Users can complete Claude onboarding from its own Admin card.
- VS Code connection status now depends on editor settings alone.
No outstanding findings remain.
</details>
<!-- greptile_confidence_score:5 -->
<sub>Reviews (3) · Last reviewed commit: ["Remove temporary Windows
profiling"](a4fcb1af63)
· [Reviewed by
Greptile](https://www.greptile.com/?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=alishahryar1/free-claude-code)</sub>
<!-- /greptile_comment -->
This commit is contained in:
parent
fa6adde24f
commit
2b55662847
21 changed files with 933 additions and 151 deletions
|
|
@ -374,6 +374,10 @@ Reload VS Code or restart the app/IDE after connecting. In Codex and Claude Desk
|
|||
model from the model picker. Reload or restart the client when FCC reports updated settings. Use
|
||||
**Disconnect** on the same card to remove the integration.
|
||||
|
||||
To complete Claude Code onboarding separately, open **Admin UI → Integrations →
|
||||
Claude Code onboarding** and click **Complete onboarding**. Connecting Claude Code
|
||||
in VS Code also completes onboarding.
|
||||
|
||||
Run FCC on the same computer and in the same user environment as the client you
|
||||
are configuring.
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningP
|
|||
from free_claude_code.harnesses import (
|
||||
claude_desktop_integration,
|
||||
claude_integration,
|
||||
claude_onboarding,
|
||||
codex_integration,
|
||||
jetbrains_acp_integration,
|
||||
vscode_chat_integration,
|
||||
|
|
@ -194,7 +195,7 @@ def admin_base_url(
|
|||
lambda: tmp_path / "vscode/chatLanguageModels.json",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
claude_integration, "claude_state_path", lambda: tmp_path / ".claude.json"
|
||||
claude_onboarding, "state_path", lambda: tmp_path / ".claude.json"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
claude_integration,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from itertools import groupby, pairwise
|
|||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from free_claude_code.harnesses import claude_integration
|
||||
from free_claude_code.harnesses import claude_integration, claude_onboarding
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -210,6 +210,7 @@ def test_connect_disconnect_and_modal_dismissal(page, admin_base_url, tmp_path):
|
|||
assert json.loads(path.read_text())["claudeCode.disableLoginPrompt"] is True
|
||||
state_path = tmp_path / ".claude.json"
|
||||
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] is True
|
||||
expect(page.locator("#openClaudeOnboarding")).to_have_text("Completed")
|
||||
page.reload()
|
||||
expect(card_button).to_have_text("Disconnect")
|
||||
card_button.click()
|
||||
|
|
@ -232,19 +233,18 @@ def test_manual_setup_and_revisit_read_the_file(page, admin_base_url, tmp_path):
|
|||
status = page.request.get(f"{admin_base_url}/admin/api/status").json()
|
||||
claude_integration.configure(
|
||||
path,
|
||||
tmp_path / ".claude.json",
|
||||
f"http://localhost:{status['port']}/",
|
||||
"e2e-proxy-token",
|
||||
True,
|
||||
)
|
||||
claude_onboarding.complete(tmp_path / ".claude.json")
|
||||
page.goto(f"{admin_base_url}/admin/integrations")
|
||||
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
|
||||
(tmp_path / ".claude.json").unlink()
|
||||
page.reload()
|
||||
expect(page.locator("#openClaudeIntegration")).to_have_text("Connect")
|
||||
page.locator("#openClaudeIntegration").click()
|
||||
expect(page.locator("#claudeIntegrationDescription")).to_contain_text("onboarding")
|
||||
page.locator("#confirmClaudeIntegration").click()
|
||||
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
|
||||
page.locator("#openClaudeOnboarding").click()
|
||||
page.locator("#confirmClaudeOnboarding").click()
|
||||
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
|
||||
page.get_by_role("button", name="Providers", exact=True).click()
|
||||
path.write_text("{}")
|
||||
|
|
@ -449,10 +449,8 @@ def test_startup_updates_before_opening_integrations(
|
|||
entry == {"name": "CLAUDE_CODE_DISABLE_ADVISOR_TOOL", "value": "1"}
|
||||
for entry in saved["claudeCode.environmentVariables"]
|
||||
)
|
||||
assert (
|
||||
json.loads((tmp_path / ".claude.json").read_text())["hasCompletedOnboarding"]
|
||||
is True
|
||||
)
|
||||
assert not (tmp_path / ".claude.json").exists()
|
||||
expect(page.locator("#openClaudeOnboarding")).to_have_text("Complete onboarding")
|
||||
page.screenshot(path=str(tmp_path / "integration-updated.png"), full_page=True)
|
||||
button.click()
|
||||
page.locator("#confirmClaudeIntegration").click()
|
||||
|
|
@ -460,3 +458,223 @@ def test_startup_updates_before_opening_integrations(
|
|||
page.reload()
|
||||
expect(button).to_have_text("Connect")
|
||||
expect(page.locator("#claudeIntegrationMessage")).to_be_hidden()
|
||||
|
||||
|
||||
def test_independent_onboarding_modal_and_completion(page, admin_base_url, tmp_path):
|
||||
state = tmp_path / ".claude.json"
|
||||
settings = tmp_path / "vscode" / "settings.json"
|
||||
page.goto(f"{admin_base_url}/admin/integrations")
|
||||
button = page.locator("#openClaudeOnboarding")
|
||||
dialog = page.locator("#claudeOnboardingDialog")
|
||||
expect(button).to_have_text("Complete onboarding")
|
||||
for dismiss in ("close", "escape", "outside"):
|
||||
button.click()
|
||||
expect(dialog).to_be_visible()
|
||||
expect(page.locator("#claudeOnboardingFiles li")).to_have_text(
|
||||
[str(state.resolve())]
|
||||
)
|
||||
if dismiss == "close":
|
||||
dialog.get_by_role("button", name="Close", exact=True).click()
|
||||
elif dismiss == "escape":
|
||||
page.keyboard.press("Escape")
|
||||
else:
|
||||
page.mouse.click(1, 1)
|
||||
expect(dialog).not_to_be_visible()
|
||||
assert not state.exists() and not settings.exists()
|
||||
button.click()
|
||||
page.locator("#confirmClaudeOnboarding").click()
|
||||
expect(dialog).not_to_be_visible()
|
||||
expect(button).to_have_text("Completed")
|
||||
expect(button).to_be_disabled()
|
||||
expect(page.locator("#openClaudeIntegration")).to_have_text("Connect")
|
||||
assert json.loads(state.read_text())["hasCompletedOnboarding"] is True
|
||||
assert not settings.exists()
|
||||
state.write_text('{"hasCompletedOnboarding":false}')
|
||||
page.get_by_role("button", name="Providers", exact=True).click()
|
||||
page.get_by_role("button", name="Integrations", exact=True).click()
|
||||
expect(button).to_have_text("Complete onboarding")
|
||||
assert state.read_text() == '{"hasCompletedOnboarding":false}'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("completed", [False, True])
|
||||
def test_onboarding_status_uses_loading_and_completed_button(
|
||||
page, admin_base_url, tmp_path, completed
|
||||
):
|
||||
pending = []
|
||||
|
||||
def hold(route):
|
||||
pending.append(route)
|
||||
page.evaluate("window.onboardingStatusIntercepted = true")
|
||||
|
||||
page.route("**/admin/api/integrations/claude-onboarding", hold)
|
||||
page.goto(f"{admin_base_url}/admin/integrations")
|
||||
button = page.locator("#openClaudeOnboarding")
|
||||
expect(button).to_have_text("Loading…")
|
||||
expect(button).to_be_disabled()
|
||||
expect(button).to_have_attribute("aria-busy", "true")
|
||||
expect(button).to_have_css("background-color", "rgb(23, 27, 38)")
|
||||
assert (
|
||||
button.evaluate(
|
||||
"element => getComputedStyle(element, '::before').animationName"
|
||||
)
|
||||
== "integration-spinner"
|
||||
)
|
||||
page.wait_for_function("window.onboardingStatusIntercepted === true")
|
||||
pending.pop().fulfill(
|
||||
json={
|
||||
"completed": completed,
|
||||
"paths": {"claude_state": str(tmp_path / ".claude.json")},
|
||||
}
|
||||
)
|
||||
expect(button).to_have_text("Completed" if completed else "Complete onboarding")
|
||||
expect(button).to_have_attribute("aria-busy", "false")
|
||||
if completed:
|
||||
expect(button).to_be_disabled()
|
||||
expect(button).to_have_css("background-color", "rgb(23, 27, 38)")
|
||||
else:
|
||||
expect(button).to_be_enabled()
|
||||
|
||||
|
||||
def test_onboarding_errors_retry_independently(page, admin_base_url, tmp_path):
|
||||
assert page.request.post(
|
||||
f"{admin_base_url}/admin/api/integrations/claude-vscode/connect"
|
||||
).ok
|
||||
state = tmp_path / ".claude.json"
|
||||
state.write_text('{"secret-state":invalid}')
|
||||
page.goto(f"{admin_base_url}/admin/integrations")
|
||||
button = page.locator("#openClaudeOnboarding")
|
||||
expect(button).to_have_text("Retry")
|
||||
expect(page.locator("#claudeOnboardingMessage")).to_contain_text(".claude.json")
|
||||
expect(page.locator("#claudeOnboardingMessage")).not_to_contain_text("secret-state")
|
||||
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
|
||||
state.write_text('{"hasCompletedOnboarding":false}')
|
||||
button.click()
|
||||
expect(button).to_have_text("Complete onboarding")
|
||||
assert state.read_text() == '{"hasCompletedOnboarding":false}'
|
||||
|
||||
def reject(route):
|
||||
route.fulfill(status=503, json={"detail": "Could not access .claude.json."})
|
||||
|
||||
endpoint = "**/admin/api/integrations/claude-onboarding/complete"
|
||||
page.route(endpoint, reject)
|
||||
button.click()
|
||||
action = page.locator("#confirmClaudeOnboarding")
|
||||
action.click()
|
||||
expect(page.locator("#claudeOnboardingDialogMessage")).to_contain_text(
|
||||
"Could not access"
|
||||
)
|
||||
expect(action).to_be_enabled()
|
||||
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
|
||||
page.unroute(endpoint, reject)
|
||||
action.click()
|
||||
expect(button).to_have_text("Completed")
|
||||
|
||||
|
||||
def test_failed_connect_updates_independent_onboarding_card(
|
||||
page, admin_base_url, tmp_path, monkeypatch
|
||||
):
|
||||
settings = tmp_path / "vscode" / "settings.json"
|
||||
write = claude_integration.atomic_write_text
|
||||
|
||||
def fail_settings(path, content):
|
||||
if path == settings:
|
||||
raise PermissionError("denied")
|
||||
write(path, content)
|
||||
|
||||
monkeypatch.setattr(claude_integration, "atomic_write_text", fail_settings)
|
||||
page.goto(f"{admin_base_url}/admin/integrations")
|
||||
expect(page.locator("#openClaudeOnboarding")).to_have_text("Complete onboarding")
|
||||
page.locator("#openClaudeIntegration").click()
|
||||
page.locator("#confirmClaudeIntegration").click()
|
||||
expect(page.locator("#claudeIntegrationDialogMessage")).to_contain_text(
|
||||
"Could not access"
|
||||
)
|
||||
expect(page.locator("#openClaudeOnboarding")).to_have_text("Completed")
|
||||
expect(page.locator("#openClaudeIntegration")).to_have_text("Connect")
|
||||
assert not settings.exists()
|
||||
assert (
|
||||
json.loads((tmp_path / ".claude.json").read_text())["hasCompletedOnboarding"]
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_late_onboarding_status_cannot_undo_connect_result(
|
||||
page, admin_base_url, tmp_path
|
||||
):
|
||||
pending = []
|
||||
checks = 0
|
||||
|
||||
def hold_first(route):
|
||||
nonlocal checks
|
||||
checks += 1
|
||||
if checks == 1:
|
||||
pending.append(route)
|
||||
else:
|
||||
route.continue_()
|
||||
|
||||
endpoint = "**/admin/api/integrations/claude-onboarding"
|
||||
page.route(endpoint, hold_first)
|
||||
page.goto(f"{admin_base_url}/admin/integrations")
|
||||
button = page.locator("#openClaudeOnboarding")
|
||||
expect(button).to_have_text("Loading…")
|
||||
page.locator("#openClaudeIntegration").click()
|
||||
page.locator("#confirmClaudeIntegration").click()
|
||||
expect(button).to_have_text("Completed")
|
||||
assert checks == 2
|
||||
with page.expect_response(
|
||||
lambda response: response.url.endswith("/claude-onboarding")
|
||||
):
|
||||
pending.pop().fulfill(
|
||||
json={
|
||||
"completed": False,
|
||||
"paths": {"claude_state": str(tmp_path / ".claude.json")},
|
||||
}
|
||||
)
|
||||
page.evaluate(
|
||||
"() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))"
|
||||
)
|
||||
expect(button).to_have_text("Completed")
|
||||
expect(button).to_be_disabled()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("save_failed", [False, True])
|
||||
def test_connect_reconciles_after_pending_onboarding_save(
|
||||
page, admin_base_url, tmp_path, save_failed
|
||||
):
|
||||
pending = []
|
||||
|
||||
def hold(route):
|
||||
pending.append(route)
|
||||
|
||||
endpoint = "**/admin/api/integrations/claude-onboarding/complete"
|
||||
page.route(endpoint, hold)
|
||||
page.goto(f"{admin_base_url}/admin/integrations")
|
||||
button = page.locator("#openClaudeOnboarding")
|
||||
expect(button).to_have_text("Complete onboarding")
|
||||
button.click()
|
||||
page.locator("#confirmClaudeOnboarding").click()
|
||||
expect(button).to_have_text("Saving…")
|
||||
page.keyboard.press("Escape")
|
||||
expect(page.locator("#claudeOnboardingDialog")).not_to_be_visible()
|
||||
page.locator("#openClaudeIntegration").click()
|
||||
page.locator("#confirmClaudeIntegration").click()
|
||||
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
|
||||
assert (
|
||||
json.loads((tmp_path / ".claude.json").read_text())["hasCompletedOnboarding"]
|
||||
is True
|
||||
)
|
||||
assert len(pending) == 1
|
||||
with page.expect_response(
|
||||
lambda response: response.url.endswith("/claude-onboarding")
|
||||
):
|
||||
pending.pop().fulfill(
|
||||
status=503 if save_failed else 200,
|
||||
json=(
|
||||
{"detail": "Temporary completion error"}
|
||||
if save_failed
|
||||
else {"completed": True, "changed": False}
|
||||
),
|
||||
)
|
||||
expect(button).to_have_text("Completed")
|
||||
expect(button).to_be_disabled()
|
||||
expect(page.locator("#claudeOnboardingMessage")).to_be_hidden()
|
||||
|
|
|
|||
|
|
@ -259,6 +259,24 @@ async def models(
|
|||
return _model_options(services)
|
||||
|
||||
|
||||
@router.get("/admin/api/integrations/claude-onboarding")
|
||||
async def claude_onboarding_status(
|
||||
request: Request,
|
||||
services: ApiServices = Depends(get_services),
|
||||
):
|
||||
require_loopback_admin(request)
|
||||
return await _integration_response(services.admin.claude_onboarding_status)
|
||||
|
||||
|
||||
@router.post("/admin/api/integrations/claude-onboarding/complete")
|
||||
async def complete_claude_onboarding(
|
||||
request: Request,
|
||||
services: ApiServices = Depends(get_services),
|
||||
):
|
||||
require_loopback_admin(request)
|
||||
return await _integration_response(services.admin.complete_claude_onboarding)
|
||||
|
||||
|
||||
@router.get("/admin/api/integrations/claude-vscode")
|
||||
async def claude_vscode_status(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -293,6 +293,7 @@ function setActiveView(viewId, { scroll = false } = {}) {
|
|||
else window.CodeSessions.deactivate();
|
||||
if (activeView.id === "integrations") {
|
||||
refreshClaudeIntegration();
|
||||
refreshClaudeOnboarding();
|
||||
refreshVSCodeChatIntegration();
|
||||
refreshJetBrainsIntegration();
|
||||
refreshCodexIntegration();
|
||||
|
|
@ -1579,6 +1580,96 @@ try {
|
|||
const claudeIntegrationDialog = byId("claudeIntegrationDialog");
|
||||
const claudeIntegration = { connected: null, busy: false, paths: null, update: null };
|
||||
const claudeIntegrationPath = "/admin/api/integrations/claude-vscode";
|
||||
const claudeOnboardingDialog = byId("claudeOnboardingDialog");
|
||||
const claudeOnboarding = { completed: null, busy: false, paths: null, refreshAfterSave: false };
|
||||
const claudeOnboardingPath = "/admin/api/integrations/claude-onboarding";
|
||||
|
||||
function renderClaudeOnboarding() {
|
||||
const { completed, busy, paths, checkRequest } = claudeOnboarding;
|
||||
const button = byId("openClaudeOnboarding");
|
||||
button.textContent = busy ? checkRequest ? "Loading…" : "Saving…" : completed === null ? "Retry" : completed ? "Completed" : "Complete onboarding";
|
||||
button.disabled = busy || completed === true;
|
||||
button.setAttribute("aria-busy", String(busy));
|
||||
const action = byId("confirmClaudeOnboarding");
|
||||
action.textContent = busy ? "Saving…" : "Complete onboarding";
|
||||
action.disabled = busy || completed !== false;
|
||||
const files = byId("claudeOnboardingFiles");
|
||||
files.replaceChildren();
|
||||
if (paths) {
|
||||
const item = document.createElement("li");
|
||||
const code = document.createElement("code");
|
||||
code.textContent = paths.claude_state;
|
||||
item.appendChild(code);
|
||||
files.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshClaudeOnboarding({ replaceCheck = false } = {}) {
|
||||
if (claudeOnboarding.busy && !claudeOnboarding.checkRequest) {
|
||||
claudeOnboarding.refreshAfterSave = true;
|
||||
return;
|
||||
}
|
||||
if (replaceCheck && claudeOnboarding.checkRequest) {
|
||||
claudeOnboarding.checkRequest = null;
|
||||
claudeOnboarding.busy = false;
|
||||
}
|
||||
const request = beginIntegrationCheck(claudeOnboarding);
|
||||
if (!request) return;
|
||||
renderClaudeOnboarding();
|
||||
try {
|
||||
const result = await api(claudeOnboardingPath);
|
||||
if (!currentIntegrationCheck(claudeOnboarding, request)) return;
|
||||
claudeOnboarding.completed = result.completed;
|
||||
claudeOnboarding.paths = result.paths;
|
||||
integrationMessage("claudeOnboardingMessage", "");
|
||||
} catch (error) {
|
||||
if (!currentIntegrationCheck(claudeOnboarding, request)) return;
|
||||
claudeOnboarding.completed = null;
|
||||
integrationMessage("claudeOnboardingMessage", error.message, true);
|
||||
} finally {
|
||||
finishIntegrationCheck(claudeOnboarding, request, renderClaudeOnboarding);
|
||||
}
|
||||
}
|
||||
|
||||
byId("openClaudeOnboarding").addEventListener("click", () => {
|
||||
if (claudeOnboarding.completed === null) {
|
||||
void refreshClaudeOnboarding();
|
||||
return;
|
||||
}
|
||||
integrationMessage("claudeOnboardingDialogMessage", "");
|
||||
claudeOnboardingDialog.showModal();
|
||||
});
|
||||
byId("confirmClaudeOnboarding").addEventListener("click", async () => {
|
||||
if (byId("confirmClaudeOnboarding").disabled) return;
|
||||
claudeOnboarding.busy = true;
|
||||
renderClaudeOnboarding();
|
||||
integrationMessage("claudeOnboardingDialogMessage", "");
|
||||
integrationMessage("claudeOnboardingMessage", "");
|
||||
try {
|
||||
const result = await api(`${claudeOnboardingPath}/complete`, { method: "POST" });
|
||||
claudeOnboarding.completed = result.completed;
|
||||
claudeOnboardingDialog.close();
|
||||
integrationMessage("claudeOnboardingMessage", "Claude onboarding completed.");
|
||||
} catch (error) {
|
||||
integrationMessage("claudeOnboardingDialogMessage", error.message, true);
|
||||
integrationMessage("claudeOnboardingMessage", error.message, true);
|
||||
} finally {
|
||||
claudeOnboarding.busy = false;
|
||||
renderClaudeOnboarding();
|
||||
if (claudeOnboarding.refreshAfterSave) {
|
||||
claudeOnboarding.refreshAfterSave = false;
|
||||
void refreshClaudeOnboarding();
|
||||
}
|
||||
}
|
||||
});
|
||||
byId("closeClaudeOnboarding").addEventListener("click", () => claudeOnboardingDialog.close());
|
||||
claudeOnboardingDialog.addEventListener("click", (event) => {
|
||||
if (event.target !== claudeOnboardingDialog) return;
|
||||
const bounds = claudeOnboardingDialog.getBoundingClientRect();
|
||||
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) {
|
||||
claudeOnboardingDialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
function integrationMessage(id, message, error = false) {
|
||||
const element = byId(id);
|
||||
|
|
@ -1650,7 +1741,7 @@ function renderClaudeIntegration() {
|
|||
byId("openClaudeIntegration").className = connected && !busy ? "danger-button" : "primary-button";
|
||||
byId("confirmClaudeIntegration").className = connected ? "danger-button" : "primary-button";
|
||||
byId("claudeIntegrationDescription").textContent = connected
|
||||
? "Remove FCC's VS Code settings. Claude onboarding stays completed."
|
||||
? "Remove FCC's VS Code settings."
|
||||
: "Will set FCC's URL and token, enable model discovery, skip VS Code login, and complete Claude onboarding.";
|
||||
const files = byId("claudeIntegrationFiles");
|
||||
files.replaceChildren();
|
||||
|
|
@ -1719,6 +1810,7 @@ byId("confirmClaudeIntegration").addEventListener("click", async () => {
|
|||
} finally {
|
||||
claudeIntegration.busy = false;
|
||||
renderClaudeIntegration();
|
||||
if (!disconnect) void refreshClaudeOnboarding({ replaceCheck: true });
|
||||
}
|
||||
});
|
||||
byId("closeClaudeIntegration").addEventListener("click", () => claudeIntegrationDialog.close());
|
||||
|
|
|
|||
|
|
@ -114,6 +114,29 @@
|
|||
<button id="confirmClaudeIntegration" class="primary-button" type="button">Connect</button>
|
||||
<p id="claudeIntegrationDialogMessage" class="message-area error" role="alert" hidden></p>
|
||||
</dialog>
|
||||
<article class="provider-strip integration-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h3>Claude Code onboarding</h3>
|
||||
<p>Complete Claude Code's first-time setup for this user.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p id="claudeOnboardingMessage" class="message-area" role="status" hidden></p>
|
||||
<button id="openClaudeOnboarding" class="primary-button" type="button" aria-busy="true" disabled>Loading…</button>
|
||||
</article>
|
||||
<dialog id="claudeOnboardingDialog" class="integration-dialog" aria-labelledby="claudeOnboardingTitle" aria-describedby="claudeOnboardingDescription">
|
||||
<div class="section-heading">
|
||||
<h3 id="claudeOnboardingTitle">Claude Code onboarding</h3>
|
||||
<button id="closeClaudeOnboarding" class="ghost-button" type="button" aria-label="Close" autofocus>×</button>
|
||||
</div>
|
||||
<p id="claudeOnboardingDescription">Mark Claude Code onboarding as completed for this user.</p>
|
||||
<div class="integration-files">
|
||||
<strong>Files to modify</strong>
|
||||
<ul id="claudeOnboardingFiles"></ul>
|
||||
</div>
|
||||
<button id="confirmClaudeOnboarding" class="primary-button" type="button">Complete onboarding</button>
|
||||
<p id="claudeOnboardingDialogMessage" class="message-area error" role="alert" hidden></p>
|
||||
</dialog>
|
||||
<article class="provider-strip integration-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ class AdminRuntimePort(Protocol):
|
|||
|
||||
async def claude_vscode_status(self) -> JsonObject: ...
|
||||
|
||||
async def claude_onboarding_status(self) -> JsonObject: ...
|
||||
|
||||
async def complete_claude_onboarding(self) -> JsonObject: ...
|
||||
|
||||
async def vscode_chat_status(self) -> JsonObject: ...
|
||||
|
||||
async def connect_vscode_chat(self) -> JsonObject: ...
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ from free_claude_code.harnesses.config_file import atomic_write_text, decode_jso
|
|||
|
||||
_ENV = "claudeCode.environmentVariables"
|
||||
_LOGIN = "claudeCode.disableLoginPrompt"
|
||||
_ONBOARDING = "hasCompletedOnboarding"
|
||||
|
||||
|
||||
def settings_path() -> Path:
|
||||
|
|
@ -30,10 +29,6 @@ def settings_path() -> Path:
|
|||
return root / "Code/User/settings.json"
|
||||
|
||||
|
||||
def claude_state_path() -> Path:
|
||||
return Path.home() / ".claude.json"
|
||||
|
||||
|
||||
def _read_object(path: Path) -> JsonObject:
|
||||
try:
|
||||
source = path.read_text(encoding="utf-8-sig")
|
||||
|
|
@ -81,12 +76,10 @@ def _connected(
|
|||
|
||||
def _connect(
|
||||
path: Path,
|
||||
state_path: Path,
|
||||
document: JsonObject,
|
||||
entries: list[JsonObject],
|
||||
values: dict[str, str],
|
||||
) -> bool:
|
||||
onboarding = _read_object(state_path)
|
||||
before = json.dumps(document, allow_nan=False)
|
||||
document[_LOGIN] = True
|
||||
remaining = dict(values)
|
||||
|
|
@ -97,32 +90,23 @@ def _connect(
|
|||
entries.extend({"name": name, "value": value} for name, value in remaining.items())
|
||||
document[_ENV] = entries
|
||||
settings_changed = json.dumps(document, allow_nan=False) != before
|
||||
onboarding_changed = onboarding.get(_ONBOARDING) is not True
|
||||
if onboarding_changed:
|
||||
onboarding[_ONBOARDING] = True
|
||||
atomic_write_text(
|
||||
state_path, json.dumps(onboarding, indent=2, allow_nan=False) + "\n"
|
||||
)
|
||||
if settings_changed:
|
||||
atomic_write_text(path, json.dumps(document, indent=2, allow_nan=False) + "\n")
|
||||
return settings_changed or onboarding_changed
|
||||
return settings_changed
|
||||
|
||||
|
||||
def refresh_connected(
|
||||
path: Path, state_path: Path, proxy_root_url: str, auth_token: str
|
||||
) -> bool:
|
||||
def refresh_connected(path: Path, proxy_root_url: str, auth_token: str) -> bool:
|
||||
"""Apply current settings only to an existing connection, even from release one."""
|
||||
path = path.resolve()
|
||||
values = claude_proxy_values(proxy_root_url, auth_token)
|
||||
document, entries = _read(path, set(values))
|
||||
if not _connected(document, entries, values):
|
||||
return False
|
||||
return _connect(path, state_path.resolve(), document, entries, values)
|
||||
return _connect(path, document, entries, values)
|
||||
|
||||
|
||||
def configure(
|
||||
path: Path,
|
||||
state_path: Path,
|
||||
proxy_root_url: str,
|
||||
auth_token: str,
|
||||
connected: bool | None = None,
|
||||
|
|
@ -131,10 +115,8 @@ def configure(
|
|||
path = path.resolve()
|
||||
values = claude_proxy_values(proxy_root_url, auth_token)
|
||||
document, entries = _read(path, set(values))
|
||||
if connected is not False:
|
||||
state_path = state_path.resolve()
|
||||
if connected is True:
|
||||
_connect(path, state_path, document, entries, values)
|
||||
_connect(path, document, entries, values)
|
||||
elif connected is False:
|
||||
before = json.dumps(document, allow_nan=False)
|
||||
document.pop(_LOGIN, None)
|
||||
|
|
@ -150,14 +132,9 @@ def configure(
|
|||
)
|
||||
if connected is not None:
|
||||
document, entries = _read(path, set(values))
|
||||
onboarding = _read_object(state_path) if connected is not False else {}
|
||||
result: JsonObject = {
|
||||
"connected": _connected(document, entries, values)
|
||||
and onboarding.get(_ONBOARDING) is True,
|
||||
}
|
||||
result: JsonObject = {"connected": _connected(document, entries, values)}
|
||||
if connected is None:
|
||||
result["paths"] = {
|
||||
"vscode_settings": str(path),
|
||||
"claude_state": str(state_path),
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
43
src/free_claude_code/harnesses/claude_onboarding.py
Normal file
43
src/free_claude_code/harnesses/claude_onboarding.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Read or complete user-global Claude Code onboarding independently of editors."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from free_claude_code.core.json_types import JsonObject
|
||||
from free_claude_code.harnesses.config_file import atomic_write_text, decode_json
|
||||
|
||||
_ONBOARDING = "hasCompletedOnboarding"
|
||||
|
||||
|
||||
def state_path() -> Path:
|
||||
return Path.home() / ".claude.json"
|
||||
|
||||
|
||||
def _read(path: Path) -> JsonObject:
|
||||
try:
|
||||
source = path.read_text(encoding="utf-8-sig")
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
document = decode_json(source)
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("Claude state must be an object")
|
||||
return cast(JsonObject, document)
|
||||
|
||||
|
||||
def status(path: Path) -> JsonObject:
|
||||
path = path.resolve()
|
||||
return {
|
||||
"completed": _read(path).get(_ONBOARDING) is True,
|
||||
"paths": {"claude_state": str(path)},
|
||||
}
|
||||
|
||||
|
||||
def complete(path: Path) -> JsonObject:
|
||||
path = path.resolve()
|
||||
document = _read(path)
|
||||
changed = document.get(_ONBOARDING) is not True
|
||||
if changed:
|
||||
document[_ONBOARDING] = True
|
||||
atomic_write_text(path, json.dumps(document, indent=2, allow_nan=False) + "\n")
|
||||
return {"completed": True, "changed": changed}
|
||||
|
|
@ -380,6 +380,12 @@ class ApplicationRuntime:
|
|||
async def claude_vscode_status(self) -> JsonObject:
|
||||
return await self._integrations.claude_vscode_status()
|
||||
|
||||
async def claude_onboarding_status(self) -> JsonObject:
|
||||
return await self._integrations.claude_onboarding_status()
|
||||
|
||||
async def complete_claude_onboarding(self) -> JsonObject:
|
||||
return await self._integrations.complete_claude_onboarding()
|
||||
|
||||
async def connect_claude_vscode(self) -> JsonObject:
|
||||
return await self._integrations.connect_claude_vscode()
|
||||
|
||||
|
|
|
|||
|
|
@ -213,7 +213,6 @@ def integration_report(settings: Settings) -> dict[str, Any]:
|
|||
),
|
||||
"claude-vscode": lambda: claude_integration.configure(
|
||||
claude_integration.settings_path(),
|
||||
claude_integration.claude_state_path(),
|
||||
url,
|
||||
token,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from free_claude_code.core.json_types import JsonObject
|
|||
from free_claude_code.harnesses import (
|
||||
claude_desktop_integration,
|
||||
claude_integration,
|
||||
claude_onboarding,
|
||||
codex_integration,
|
||||
dsh_desktop_integration,
|
||||
jetbrains_acp_integration,
|
||||
|
|
@ -408,6 +409,38 @@ class IntegrationService:
|
|||
async def disconnect_claude_vscode(self) -> JsonObject:
|
||||
return await self._claude_vscode("disconnect")
|
||||
|
||||
async def claude_onboarding_status(self) -> JsonObject:
|
||||
return await self._claude_onboarding(complete=False)
|
||||
|
||||
async def complete_claude_onboarding(self) -> JsonObject:
|
||||
return await self._claude_onboarding(complete=True)
|
||||
|
||||
async def _claude_onboarding(self, *, complete: bool) -> JsonObject:
|
||||
def operate() -> JsonObject:
|
||||
operation = (
|
||||
claude_onboarding.complete if complete else claude_onboarding.status
|
||||
)
|
||||
return operation(claude_onboarding.state_path())
|
||||
|
||||
try:
|
||||
if complete:
|
||||
async with self._config_lock, self._claude_update.access.write():
|
||||
self._check_integration_available()
|
||||
return await run_sync_owned(operate)
|
||||
async with self._claude_update.access.read():
|
||||
self._check_integration_available()
|
||||
result = await run_sync_owned(operate)
|
||||
self._check_integration_available()
|
||||
return result
|
||||
except ValueError, UnicodeError:
|
||||
raise InvalidRequestError(
|
||||
"Could not read Claude onboarding state. Check the JSON in .claude.json."
|
||||
) from None
|
||||
except OSError:
|
||||
raise ApplicationUnavailableError(
|
||||
"Could not access .claude.json. Check file permissions and try again."
|
||||
) from None
|
||||
|
||||
def _check_integration_available(self) -> None:
|
||||
if self._is_draining() or self._has_pending_restart():
|
||||
raise ApplicationUnavailableError(
|
||||
|
|
@ -608,40 +641,55 @@ class IntegrationService:
|
|||
async def _claude_vscode(self, action: IntegrationAction) -> JsonObject:
|
||||
def operate(url: str, token: str, ready: bool) -> JsonObject:
|
||||
path = claude_integration.settings_path()
|
||||
state_path = claude_integration.claude_state_path()
|
||||
if action == "status" and not ready:
|
||||
return {
|
||||
"connected": None,
|
||||
"paths": {
|
||||
"vscode_settings": str(path.resolve()),
|
||||
"claude_state": str(state_path.resolve()),
|
||||
"claude_state": str(claude_onboarding.state_path().absolute()),
|
||||
},
|
||||
}
|
||||
if action == "refresh":
|
||||
return {
|
||||
"changed": claude_integration.refresh_connected(
|
||||
path, state_path, url, token
|
||||
)
|
||||
"changed": claude_integration.refresh_connected(path, url, token)
|
||||
}
|
||||
return claude_integration.configure(
|
||||
if action == "connect":
|
||||
claude_integration.configure(path, url, token)
|
||||
claude_onboarding.complete(claude_onboarding.state_path())
|
||||
result = claude_integration.configure(
|
||||
path,
|
||||
state_path,
|
||||
url,
|
||||
token,
|
||||
None if action == "status" else action == "connect",
|
||||
)
|
||||
if action == "status":
|
||||
result["paths"] = {
|
||||
"vscode_settings": str(path.resolve()),
|
||||
"claude_state": str(claude_onboarding.state_path().absolute()),
|
||||
}
|
||||
return result
|
||||
|
||||
try:
|
||||
return await self._run_integration(
|
||||
self._claude_update, action, operate, mask_when_unready=True
|
||||
)
|
||||
except ValueError, UnicodeError:
|
||||
files = (
|
||||
"VS Code settings.json and .claude.json"
|
||||
if action == "connect"
|
||||
else "VS Code settings.json"
|
||||
)
|
||||
raise InvalidRequestError(
|
||||
"Could not read Claude integration settings. Check the JSON in VS Code settings.json and .claude.json."
|
||||
f"Could not read Claude integration settings. Check the JSON in {files}."
|
||||
) from None
|
||||
except OSError:
|
||||
files = (
|
||||
"VS Code settings.json or .claude.json"
|
||||
if action == "connect"
|
||||
else "VS Code settings.json"
|
||||
)
|
||||
raise ApplicationUnavailableError(
|
||||
"Could not access VS Code settings.json or .claude.json. Check file permissions and try again."
|
||||
f"Could not access {files}. Check file permissions and try again."
|
||||
) from None
|
||||
|
||||
async def codex_integration_status(self) -> JsonObject:
|
||||
|
|
|
|||
120
tests/api/test_claude_onboarding.py
Normal file
120
tests/api/test_claude_onboarding.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from free_claude_code.harnesses import claude_integration, claude_onboarding
|
||||
from tests.api.support import create_test_app, runtime_for_app
|
||||
|
||||
ROOT = "/admin/api/integrations/claude-onboarding"
|
||||
VSCODE = "/admin/api/integrations/claude-vscode"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def onboarding():
|
||||
app = create_test_app()
|
||||
with TestClient(
|
||||
app, base_url="http://127.0.0.1", client=("127.0.0.1", 50000)
|
||||
) as client:
|
||||
yield client, runtime_for_app(app), claude_onboarding.state_path()
|
||||
|
||||
|
||||
def test_passive_status_and_independent_completion(onboarding):
|
||||
client, runtime, path = onboarding
|
||||
runtime.provider_manager.wait_for_catalog = AsyncMock(
|
||||
side_effect=AssertionError("No model discovery needed")
|
||||
)
|
||||
runtime._integrations._claude_update.state = "failed"
|
||||
runtime._integrations._claude_update.message = "VS Code unavailable"
|
||||
result = client.get(ROOT)
|
||||
assert result.status_code == 200
|
||||
assert result.headers["cache-control"] == "no-store"
|
||||
assert result.json() == {
|
||||
"completed": False,
|
||||
"paths": {"claude_state": str(path.resolve())},
|
||||
}
|
||||
assert not path.exists()
|
||||
response = client.post(f"{ROOT}/complete")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"completed": True, "changed": True}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert json.loads(path.read_text())["hasCompletedOnboarding"] is True
|
||||
assert not claude_integration.settings_path().exists()
|
||||
assert client.get(ROOT).json()["completed"] is True
|
||||
before = path.stat().st_mtime_ns
|
||||
assert client.post(f"{ROOT}/complete").json() == {
|
||||
"completed": True,
|
||||
"changed": False,
|
||||
}
|
||||
assert path.stat().st_mtime_ns == before
|
||||
assert runtime._integrations._claude_update.state == "failed"
|
||||
assert runtime._integrations._claude_update.message == "VS Code unavailable"
|
||||
runtime.provider_manager.wait_for_catalog.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["", "/complete"])
|
||||
def test_invalid_onboarding_is_scoped_and_does_not_change_connection(
|
||||
onboarding, action
|
||||
):
|
||||
client, _, path = onboarding
|
||||
assert client.post(f"{VSCODE}/connect").status_code == 200
|
||||
source = '{"onboarding-secret": invalid}'
|
||||
path.write_text(source)
|
||||
response = client.request("POST" if action else "GET", ROOT + action)
|
||||
assert response.status_code == 400
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "onboarding-secret" not in response.text
|
||||
assert ".claude.json" in response.json()["detail"]
|
||||
assert path.read_text() == source
|
||||
assert client.get(VSCODE).json()["connected"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["", "/complete"])
|
||||
@pytest.mark.parametrize(
|
||||
"headers", [{"Host": "evil.test"}, {"Origin": "https://evil.test"}]
|
||||
)
|
||||
def test_loopback_admin_guard(onboarding, action, headers):
|
||||
client, _, path = onboarding
|
||||
assert (
|
||||
client.request(
|
||||
"POST" if action else "GET", ROOT + action, headers=headers
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_remote_client_is_rejected(onboarding):
|
||||
client, _, path = onboarding
|
||||
with TestClient(
|
||||
client.app, base_url="http://127.0.0.1", client=("192.0.2.1", 50000)
|
||||
) as remote:
|
||||
assert remote.get(ROOT).status_code == 403
|
||||
assert remote.post(f"{ROOT}/complete").status_code == 403
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_pending_restart_and_shutdown_do_not_write(onboarding):
|
||||
client, runtime, path = onboarding
|
||||
runtime._pending_fields = ["PORT"]
|
||||
assert client.post(f"{ROOT}/complete").status_code == 503
|
||||
runtime._pending_fields = []
|
||||
runtime.begin_shutdown()
|
||||
assert client.post(f"{ROOT}/complete").status_code == 503
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["", "/complete"])
|
||||
def test_io_failure_returns_safe_error(onboarding, monkeypatch, action):
|
||||
client, _, path = onboarding
|
||||
|
||||
def fail(*args):
|
||||
raise PermissionError("onboarding-secret")
|
||||
|
||||
monkeypatch.setattr(claude_onboarding, "complete" if action else "status", fail)
|
||||
response = client.request("POST" if action else "GET", ROOT + action)
|
||||
assert response.status_code == 503
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "onboarding-secret" not in response.text
|
||||
assert not path.exists()
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from free_claude_code.config.settings import Settings
|
||||
from free_claude_code.harnesses import claude_integration
|
||||
from free_claude_code.harnesses import claude_integration, claude_onboarding
|
||||
from tests.api.support import create_test_app, runtime_for_app
|
||||
|
||||
ROOT = "/admin/api/integrations/claude-vscode"
|
||||
|
|
@ -15,7 +16,7 @@ def integration(tmp_path, monkeypatch):
|
|||
path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(claude_integration, "settings_path", lambda: path)
|
||||
monkeypatch.setattr(
|
||||
claude_integration, "claude_state_path", lambda: tmp_path / ".claude.json"
|
||||
claude_onboarding, "state_path", lambda: tmp_path / ".claude.json"
|
||||
)
|
||||
app = create_test_app(
|
||||
Settings(host="0.0.0.0", port=4321, proxy_auth_token="integration-secret")
|
||||
|
|
@ -45,7 +46,7 @@ def test_routes_read_actual_file_connect_and_disconnect(integration):
|
|||
state_path = path.parent / ".claude.json"
|
||||
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] is True
|
||||
state_path.write_text('{"hasCompletedOnboarding":false}')
|
||||
assert client.get(ROOT).json()["connected"] is False
|
||||
assert client.get(ROOT).json()["connected"] is True
|
||||
assert client.post(f"{ROOT}/connect").json() == {"connected": True}
|
||||
path.write_text("{}")
|
||||
assert client.get(ROOT).json()["connected"] is False
|
||||
|
|
@ -53,6 +54,26 @@ def test_routes_read_actual_file_connect_and_disconnect(integration):
|
|||
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source", [None, '{"hasCompletedOnboarding":false}', "{invalid", "[]"]
|
||||
)
|
||||
def test_connection_status_ignores_global_onboarding(integration, source):
|
||||
client, path, _ = integration
|
||||
assert client.post(f"{ROOT}/connect").status_code == 200
|
||||
state_path = path.parent / ".claude.json"
|
||||
if source is None:
|
||||
state_path.unlink()
|
||||
else:
|
||||
state_path.write_text(source)
|
||||
response = client.get(ROOT)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["connected"] is True
|
||||
if source is None:
|
||||
assert not state_path.exists()
|
||||
else:
|
||||
assert state_path.read_text() == source
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["vscode_settings", "claude_state"])
|
||||
def test_status_reports_resolved_file_paths_without_creating_files(integration, field):
|
||||
client, path, _ = integration
|
||||
|
|
@ -74,7 +95,8 @@ def test_status_reports_resolved_file_paths_without_creating_files(integration,
|
|||
if getattr(exc, "winerror", None) == 1314:
|
||||
pytest.skip("Windows symlink creation requires Developer Mode or privilege")
|
||||
raise
|
||||
assert client.get(ROOT).json()["paths"][field] == str(target.resolve())
|
||||
expected = target.resolve() if field == "vscode_settings" else state_path.absolute()
|
||||
assert client.get(ROOT).json()["paths"][field] == str(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["", "/connect", "/disconnect"])
|
||||
|
|
@ -86,7 +108,7 @@ def test_invalid_file_returns_safe_uncached_error(integration, action, target):
|
|||
path = path.parent / ".claude.json"
|
||||
path.write_text(source)
|
||||
response = client.request("GET" if not action else "POST", ROOT + action)
|
||||
expected_error = target == "settings" or action != "/disconnect"
|
||||
expected_error = target == "settings" or action == "/connect"
|
||||
assert response.status_code == (400 if expected_error else 200)
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "integration-secret" not in response.text
|
||||
|
|
@ -129,6 +151,57 @@ def test_io_failure_returns_safe_error(integration, monkeypatch):
|
|||
assert "integration-secret" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failed_file", ["state", "settings"])
|
||||
def test_connect_partial_write_is_retryable(integration, monkeypatch, failed_file):
|
||||
client, path, _ = integration
|
||||
state = claude_onboarding.state_path()
|
||||
path.write_text('{"keep":true}')
|
||||
original_replace = Path.replace
|
||||
|
||||
def fail(self, target):
|
||||
if target == (state if failed_file == "state" else path):
|
||||
raise PermissionError("denied")
|
||||
return original_replace(self, target)
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(Path, "replace", fail)
|
||||
assert client.post(f"{ROOT}/connect").status_code == 503
|
||||
assert path.read_text() == '{"keep":true}'
|
||||
assert client.get(ROOT).json()["connected"] is False
|
||||
if failed_file == "state":
|
||||
assert not state.exists()
|
||||
else:
|
||||
assert json.loads(state.read_text())["hasCompletedOnboarding"] is True
|
||||
assert client.post(f"{ROOT}/connect").json() == {"connected": True}
|
||||
|
||||
|
||||
def test_invalid_vscode_preflight_does_not_complete_onboarding(integration):
|
||||
client, path, _ = integration
|
||||
path.write_text('{"claudeCode.environmentVariables":{}}')
|
||||
state = claude_onboarding.state_path()
|
||||
state.write_text('{"hasCompletedOnboarding":false}')
|
||||
assert client.post(f"{ROOT}/connect").status_code == 400
|
||||
assert state.read_text() == '{"hasCompletedOnboarding":false}'
|
||||
|
||||
|
||||
def test_connection_status_does_not_resolve_global_state(integration, monkeypatch):
|
||||
client, _, _ = integration
|
||||
assert client.post(f"{ROOT}/connect").status_code == 200
|
||||
state = claude_onboarding.state_path()
|
||||
resolve = Path.resolve
|
||||
|
||||
def fail_global(self, *args, **kwargs):
|
||||
if self == state:
|
||||
raise OSError("global state is inaccessible")
|
||||
return resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fail_global)
|
||||
response = client.get(ROOT)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["connected"] is True
|
||||
assert response.json()["paths"]["claude_state"] == str(state.absolute())
|
||||
|
||||
|
||||
def test_pending_restart_and_shutdown_do_not_write(integration):
|
||||
client, path, runtime = integration
|
||||
runtime._pending_fields = ["PORT"]
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ def test_complete_report_reads_real_files_without_mutation(
|
|||
claude = diagnostics.claude_integration
|
||||
claude.configure(
|
||||
claude.settings_path(),
|
||||
claude.claude_state_path(),
|
||||
local_proxy_root_url(settings),
|
||||
settings.proxy_auth_token,
|
||||
connected=True,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from free_claude_code.config.loader import clear_settings_cache
|
|||
from free_claude_code.harnesses import (
|
||||
claude_desktop_integration,
|
||||
claude_integration,
|
||||
claude_onboarding,
|
||||
codex_integration,
|
||||
jetbrains_acp_integration,
|
||||
vscode_chat_integration,
|
||||
|
|
@ -71,7 +72,7 @@ def _isolate_managed_config(monkeypatch, tmp_path):
|
|||
claude_integration, "settings_path", lambda: tmp_path / "vscode/settings.json"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
claude_integration, "claude_state_path", lambda: tmp_path / ".claude.json"
|
||||
claude_onboarding, "state_path", lambda: tmp_path / ".claude.json"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
codex_integration, "config_path", lambda: tmp_path / ".codex/config.toml"
|
||||
|
|
|
|||
|
|
@ -13,23 +13,18 @@ LOGIN = "claudeCode.disableLoginPrompt"
|
|||
|
||||
|
||||
def operate(path, connected=None):
|
||||
result = claude_integration.configure(
|
||||
path, path.parent / ".claude.json", URL, TOKEN, connected
|
||||
)
|
||||
result = claude_integration.configure(path, URL, TOKEN, connected)
|
||||
return {"connected": result["connected"]}
|
||||
|
||||
|
||||
def test_connect_completes_onboarding_and_disconnect_preserves_state(tmp_path):
|
||||
def test_vscode_operations_preserve_global_state(tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
state_path = tmp_path / ".claude.json"
|
||||
state_path.write_text('{"theme":"dark","hasCompletedOnboarding":false}')
|
||||
assert operate(path, True) == {"connected": True}
|
||||
assert json.loads(state_path.read_text()) == {
|
||||
"theme": "dark",
|
||||
"hasCompletedOnboarding": True,
|
||||
}
|
||||
saved = state_path.read_bytes()
|
||||
modified = state_path.stat().st_mtime_ns
|
||||
assert operate(path) == {"connected": True}
|
||||
operate(path, True)
|
||||
assert state_path.stat().st_mtime_ns == modified
|
||||
assert operate(path, False) == {"connected": False}
|
||||
|
|
@ -37,40 +32,38 @@ def test_connect_completes_onboarding_and_disconnect_preserves_state(tmp_path):
|
|||
|
||||
|
||||
@pytest.mark.parametrize("flag", [None, False, 1, "true"])
|
||||
def test_connected_requires_completed_onboarding(tmp_path, flag):
|
||||
def test_connection_is_independent_of_onboarding(tmp_path, flag):
|
||||
path = tmp_path / "settings.json"
|
||||
state_path = tmp_path / ".claude.json"
|
||||
operate(path, True)
|
||||
state_path.write_text(json.dumps({"hasCompletedOnboarding": flag}))
|
||||
assert operate(path) == {"connected": False}
|
||||
assert operate(path) == {"connected": True}
|
||||
operate(path, True)
|
||||
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] is True
|
||||
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] == flag
|
||||
assert operate(path) == {"connected": True}
|
||||
|
||||
|
||||
def test_missing_onboarding_state_is_read_only_until_connect(tmp_path):
|
||||
def test_vscode_operations_do_not_create_global_state(tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
state_path = tmp_path / ".claude.json"
|
||||
operate(path, True)
|
||||
state_path.unlink()
|
||||
assert operate(path) == {"connected": False}
|
||||
assert operate(path) == {"connected": True}
|
||||
assert not state_path.exists()
|
||||
assert operate(path, False) == {"connected": False}
|
||||
assert not state_path.exists()
|
||||
operate(path, True)
|
||||
assert json.loads(state_path.read_text()) == {"hasCompletedOnboarding": True}
|
||||
assert not state_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["{invalid", "[]", '{"a":1,"a":2}', '{"a":NaN}'])
|
||||
def test_invalid_state_blocks_connect_but_not_disconnect(tmp_path, source):
|
||||
def test_invalid_global_state_does_not_affect_vscode(tmp_path, source):
|
||||
path = tmp_path / "settings.json"
|
||||
state_path = tmp_path / ".claude.json"
|
||||
operate(path, True)
|
||||
settings_before = path.read_bytes()
|
||||
state_path.write_text(source)
|
||||
for action in (None, True):
|
||||
with pytest.raises(ValueError):
|
||||
operate(path, action)
|
||||
assert operate(path, action) == {"connected": True}
|
||||
assert path.read_bytes() == settings_before
|
||||
assert state_path.read_text() == source
|
||||
assert operate(path, False) == {"connected": False}
|
||||
|
|
@ -88,52 +81,6 @@ def test_invalid_vscode_settings_do_not_update_onboarding(tmp_path):
|
|||
assert state_path.read_text() == '{"hasCompletedOnboarding":false}'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failed_file", ["state", "settings"])
|
||||
def test_save_failure_preserves_completed_steps_and_retry_completes(
|
||||
tmp_path, monkeypatch, failed_file
|
||||
):
|
||||
path = tmp_path / "settings.json"
|
||||
state_path = tmp_path / ".claude.json"
|
||||
path.write_text('{"keep":true}')
|
||||
original_replace = Path.replace
|
||||
|
||||
def fail_replace(self, target):
|
||||
if target == (state_path if failed_file == "state" else path):
|
||||
raise PermissionError("test failure")
|
||||
return original_replace(self, target)
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(Path, "replace", fail_replace)
|
||||
with pytest.raises(OSError):
|
||||
operate(path, True)
|
||||
if failed_file == "state":
|
||||
assert not state_path.exists()
|
||||
else:
|
||||
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] is True
|
||||
assert path.read_text() == '{"keep":true}'
|
||||
assert operate(path) == {"connected": False}
|
||||
assert operate(path, True) == {"connected": True}
|
||||
|
||||
|
||||
def test_onboarding_symlink_and_escaped_unicode_are_preserved(tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
state_path = tmp_path / ".claude.json"
|
||||
target = tmp_path / "state.json"
|
||||
target.write_text(json.dumps({"keep": "\U0001f600"}))
|
||||
try:
|
||||
state_path.symlink_to(Path("state.json"))
|
||||
except OSError as exc:
|
||||
if getattr(exc, "winerror", None) == 1314:
|
||||
pytest.skip("Windows symlink creation requires Developer Mode or privilege")
|
||||
raise
|
||||
assert operate(path, True) == {"connected": True}
|
||||
assert state_path.is_symlink()
|
||||
assert json.loads(target.read_text()) == {
|
||||
"keep": "\U0001f600",
|
||||
"hasCompletedOnboarding": True,
|
||||
}
|
||||
|
||||
|
||||
def test_connect_merges_jsonc_and_disconnect_preserves_unrelated_values(tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
path.write_text(
|
||||
|
|
@ -240,7 +187,6 @@ def test_settings_symlink_is_preserved(tmp_path, connected, target_exists):
|
|||
@pytest.mark.parametrize("url", ["http://localhost:8000/", URL, "http://[::1]:8000"])
|
||||
def test_manual_setup_only_requires_connection_fields(tmp_path, url):
|
||||
path = tmp_path / "settings.json"
|
||||
(tmp_path / ".claude.json").write_text('{"hasCompletedOnboarding":true}')
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
|
|
|
|||
91
tests/harnesses/test_claude_onboarding.py
Normal file
91
tests/harnesses/test_claude_onboarding.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from free_claude_code.harnesses import claude_onboarding
|
||||
|
||||
|
||||
def test_missing_state_status_does_not_create_files(tmp_path):
|
||||
path = tmp_path / "missing" / ".claude.json"
|
||||
assert claude_onboarding.status(path) == {
|
||||
"completed": False,
|
||||
"paths": {"claude_state": str(path.resolve())},
|
||||
}
|
||||
assert not path.parent.exists()
|
||||
assert claude_onboarding.complete(path) == {"completed": True, "changed": True}
|
||||
assert json.loads(path.read_text()) == {"hasCompletedOnboarding": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", [None, False, 1, "true", True])
|
||||
def test_complete_preserves_values_and_is_idempotent(tmp_path, flag):
|
||||
path = tmp_path / ".claude.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{"theme": "dark", "keep": "\U0001f600", "hasCompletedOnboarding": flag}
|
||||
)
|
||||
)
|
||||
before = path.read_bytes(), path.stat().st_mtime_ns
|
||||
assert claude_onboarding.status(path)["completed"] is (flag is True)
|
||||
assert (path.read_bytes(), path.stat().st_mtime_ns) == before
|
||||
assert claude_onboarding.complete(path) == {
|
||||
"completed": True,
|
||||
"changed": flag is not True,
|
||||
}
|
||||
assert json.loads(path.read_text()) == {
|
||||
"theme": "dark",
|
||||
"keep": "\U0001f600",
|
||||
"hasCompletedOnboarding": True,
|
||||
}
|
||||
before = path.read_bytes(), path.stat().st_mtime_ns
|
||||
assert claude_onboarding.complete(path) == {"completed": True, "changed": False}
|
||||
assert (path.read_bytes(), path.stat().st_mtime_ns) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["{invalid", "[]", '{"a":1,"a":2}', '{"a":NaN}'])
|
||||
def test_invalid_state_is_never_overwritten(tmp_path, source):
|
||||
path = tmp_path / ".claude.json"
|
||||
path.write_text(source)
|
||||
for operation in (claude_onboarding.status, claude_onboarding.complete):
|
||||
with pytest.raises(ValueError):
|
||||
operation(path)
|
||||
assert path.read_text() == source
|
||||
|
||||
|
||||
def test_failed_write_preserves_original_and_removes_temporary(tmp_path, monkeypatch):
|
||||
path = tmp_path / ".claude.json"
|
||||
path.write_text('{"theme":"dark"}')
|
||||
|
||||
def fail(self, target):
|
||||
raise PermissionError("denied")
|
||||
|
||||
monkeypatch.setattr(Path, "replace", fail)
|
||||
with pytest.raises(PermissionError):
|
||||
claude_onboarding.complete(path)
|
||||
assert path.read_text() == '{"theme":"dark"}'
|
||||
assert list(tmp_path.iterdir()) == [path]
|
||||
|
||||
|
||||
def test_symlink_is_preserved(tmp_path):
|
||||
target, link = tmp_path / "state.json", tmp_path / ".claude.json"
|
||||
target.write_text('{"theme":"dark"}')
|
||||
try:
|
||||
link.symlink_to(target.name)
|
||||
except OSError as exc:
|
||||
if getattr(exc, "winerror", None) == 1314:
|
||||
pytest.skip("Windows symlink creation requires Developer Mode or privilege")
|
||||
raise
|
||||
assert claude_onboarding.status(link)["paths"] == {
|
||||
"claude_state": str(target.resolve())
|
||||
}
|
||||
assert claude_onboarding.complete(link) == {"completed": True, "changed": True}
|
||||
assert link.is_symlink()
|
||||
assert json.loads(target.read_text()) == {
|
||||
"theme": "dark",
|
||||
"hasCompletedOnboarding": True,
|
||||
}
|
||||
|
||||
|
||||
def test_default_state_path(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
assert claude_onboarding.state_path() == tmp_path / ".claude.json"
|
||||
|
|
@ -28,14 +28,27 @@ base_url = "https://example.com/v1"
|
|||
"""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source", [None, '{"hasCompletedOnboarding":false}', "{invalid"]
|
||||
)
|
||||
def test_refresh_ignores_global_onboarding(tmp_path, source):
|
||||
settings, state = tmp_path / "settings.json", tmp_path / ".claude.json"
|
||||
settings.write_text(json.dumps(OLD_CLAUDE))
|
||||
if source is not None:
|
||||
state.write_text(source)
|
||||
assert claude_integration.refresh_connected(settings, URL, TOKEN) is True
|
||||
if source is None:
|
||||
assert not state.exists()
|
||||
else:
|
||||
assert state.read_text() == source
|
||||
|
||||
|
||||
def test_refresh_upgrades_original_claude_directly_and_only_once(tmp_path):
|
||||
settings, state = tmp_path / "settings.json", tmp_path / ".claude.json"
|
||||
settings.write_text(json.dumps(OLD_CLAUDE))
|
||||
state.write_text('{"theme":"dark"}')
|
||||
assert (
|
||||
claude_integration.configure(settings, state, URL, TOKEN)["connected"] is False
|
||||
)
|
||||
assert claude_integration.refresh_connected(settings, state, URL, TOKEN) is True
|
||||
assert claude_integration.configure(settings, URL, TOKEN)["connected"] is True
|
||||
assert claude_integration.refresh_connected(settings, URL, TOKEN) is True
|
||||
saved = json.loads(settings.read_text())
|
||||
env = {
|
||||
entry["name"]: entry["value"]
|
||||
|
|
@ -45,15 +58,10 @@ def test_refresh_upgrades_original_claude_directly_and_only_once(tmp_path):
|
|||
assert env["CLAUDE_CODE_AUTO_MODE_SERVER"] == "0"
|
||||
assert env["KEEP_ME"] == "user-value"
|
||||
assert saved["editor.fontSize"] == 15
|
||||
assert json.loads(state.read_text()) == {
|
||||
"theme": "dark",
|
||||
"hasCompletedOnboarding": True,
|
||||
}
|
||||
assert (
|
||||
claude_integration.configure(settings, state, URL, TOKEN)["connected"] is True
|
||||
)
|
||||
assert state.read_text() == '{"theme":"dark"}'
|
||||
assert claude_integration.configure(settings, URL, TOKEN)["connected"] is True
|
||||
before = [path.stat().st_mtime_ns for path in (settings, state)]
|
||||
assert claude_integration.refresh_connected(settings, state, URL, TOKEN) is False
|
||||
assert claude_integration.refresh_connected(settings, URL, TOKEN) is False
|
||||
assert [path.stat().st_mtime_ns for path in (settings, state)] == before
|
||||
|
||||
|
||||
|
|
@ -72,7 +80,7 @@ def test_refresh_does_not_claim_unrecognized_claude(tmp_path, change):
|
|||
before = settings.read_bytes() if settings.exists() else None
|
||||
# Unrelated Claude state is not even parsed when the integration is not ours.
|
||||
state.write_text("{invalid")
|
||||
assert claude_integration.refresh_connected(settings, state, URL, TOKEN) is False
|
||||
assert claude_integration.refresh_connected(settings, URL, TOKEN) is False
|
||||
assert (settings.read_bytes() if settings.exists() else None) == before
|
||||
assert state.read_text() == "{invalid"
|
||||
|
||||
|
|
@ -91,13 +99,11 @@ def test_refresh_claude_retry_finishes_partial_write(tmp_path, monkeypatch):
|
|||
with monkeypatch.context() as patch:
|
||||
patch.setattr(claude_integration, "atomic_write_text", fail_settings)
|
||||
with pytest.raises(PermissionError):
|
||||
claude_integration.refresh_connected(settings, state, URL, TOKEN)
|
||||
claude_integration.refresh_connected(settings, URL, TOKEN)
|
||||
assert settings.read_bytes() == before
|
||||
assert json.loads(state.read_text())["hasCompletedOnboarding"] is True
|
||||
assert claude_integration.refresh_connected(settings, state, URL, TOKEN) is True
|
||||
assert (
|
||||
claude_integration.configure(settings, state, URL, TOKEN)["connected"] is True
|
||||
)
|
||||
assert not state.exists()
|
||||
assert claude_integration.refresh_connected(settings, URL, TOKEN) is True
|
||||
assert claude_integration.configure(settings, URL, TOKEN)["connected"] is True
|
||||
|
||||
|
||||
def test_refresh_codex_merges_latest_settings_without_changing_model(tmp_path):
|
||||
|
|
@ -148,9 +154,7 @@ def test_refresh_rejects_malformed_input_without_writing(tmp_path, integration):
|
|||
path.write_text("{invalid")
|
||||
with pytest.raises(ValueError):
|
||||
if integration == "claude":
|
||||
claude_integration.refresh_connected(
|
||||
path, tmp_path / ".claude.json", URL, TOKEN
|
||||
)
|
||||
claude_integration.refresh_connected(path, URL, TOKEN)
|
||||
else:
|
||||
codex_integration.refresh_connected(path, tmp_path / "catalog.json", URL)
|
||||
assert path.read_text() == "{invalid"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from free_claude_code.harnesses import (
|
|||
)
|
||||
from free_claude_code.harnesses import (
|
||||
claude_integration,
|
||||
claude_onboarding,
|
||||
codex_integration,
|
||||
)
|
||||
from free_claude_code.harnesses import (
|
||||
|
|
@ -148,6 +149,7 @@ async def test_startup_refreshes_old_connections_and_status_is_read_only(runtime
|
|||
await runtime.claude_vscode_status()
|
||||
await runtime.codex_integration_status()
|
||||
assert [p.stat().st_mtime_ns for p in (claude, codex)] == before
|
||||
assert not claude_onboarding.state_path().exists()
|
||||
await runtime.disconnect_claude_vscode()
|
||||
assert (await runtime.claude_vscode_status())["update"]["changed"] is False
|
||||
await runtime.refresh_claude_vscode()
|
||||
|
|
@ -157,6 +159,21 @@ async def test_startup_refreshes_old_connections_and_status_is_read_only(runtime
|
|||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("source", ['{"hasCompletedOnboarding":false}', "{invalid"])
|
||||
async def test_startup_preserves_global_onboarding(runtime, source):
|
||||
old_connections()
|
||||
state = claude_onboarding.state_path()
|
||||
state.write_text(source)
|
||||
try:
|
||||
await runtime.start()
|
||||
await finish(runtime)
|
||||
assert (await runtime.claude_vscode_status())["connected"] is True
|
||||
assert state.read_text() == source
|
||||
finally:
|
||||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_wait_does_not_delay_http_or_claude(runtime, monkeypatch):
|
||||
old_connections()
|
||||
|
|
@ -208,7 +225,7 @@ async def test_unrecognized_codex_never_waits_for_catalog(runtime, monkeypatch):
|
|||
wait.assert_not_awaited()
|
||||
assert (await runtime.codex_integration_status())["connected"] is False
|
||||
assert not codex_integration.config_path().exists()
|
||||
assert not claude_integration.claude_state_path().exists()
|
||||
assert not claude_onboarding.state_path().exists()
|
||||
finally:
|
||||
await runtime.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
|
@ -6,6 +7,7 @@ import pytest
|
|||
|
||||
from free_claude_code.application.errors import ApplicationUnavailableError
|
||||
from free_claude_code.harnesses import claude_integration as claude
|
||||
from free_claude_code.harnesses import claude_onboarding
|
||||
from free_claude_code.harnesses import vscode_chat_integration as vscode
|
||||
from tests.runtime.test_integration_startup import runtime as runtime
|
||||
|
||||
|
|
@ -98,20 +100,20 @@ async def test_cancelled_reader_drains_before_writer_enters(runtime, monkeypatch
|
|||
async def test_status_cannot_observe_partial_multifile_write(runtime, monkeypatch):
|
||||
entered, release = asyncio.Event(), threading.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
atomic = claude.atomic_write_text
|
||||
atomic = claude_onboarding.atomic_write_text
|
||||
|
||||
def held(path, content):
|
||||
atomic(path, content)
|
||||
if path == claude.claude_state_path():
|
||||
if path == claude_onboarding.state_path():
|
||||
loop.call_soon_threadsafe(entered.set)
|
||||
assert release.wait(5)
|
||||
|
||||
monkeypatch.setattr(claude, "atomic_write_text", held)
|
||||
monkeypatch.setattr(claude_onboarding, "atomic_write_text", held)
|
||||
writer = asyncio.create_task(runtime.connect_claude_vscode())
|
||||
reader = None
|
||||
try:
|
||||
await asyncio.wait_for(entered.wait(), 5)
|
||||
assert claude.claude_state_path().exists()
|
||||
assert claude_onboarding.state_path().exists()
|
||||
assert not claude.settings_path().exists()
|
||||
reader = asyncio.create_task(runtime.claude_vscode_status())
|
||||
assert (await asyncio.wait_for(runtime.jetbrains_acp_status(), 1))[
|
||||
|
|
@ -129,12 +131,108 @@ async def test_status_cannot_observe_partial_multifile_write(runtime, monkeypatc
|
|||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("first", ["connect", "complete"])
|
||||
async def test_connect_and_onboarding_share_write_ownership(
|
||||
runtime, monkeypatch, first
|
||||
):
|
||||
entered, release = asyncio.Event(), threading.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
complete = claude_onboarding.complete
|
||||
calls = 0
|
||||
path = claude_onboarding.state_path()
|
||||
path.write_text('{"theme":"dark"}')
|
||||
|
||||
def held(path):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
loop.call_soon_threadsafe(entered.set)
|
||||
assert release.wait(5)
|
||||
return complete(path)
|
||||
|
||||
monkeypatch.setattr(claude_onboarding, "complete", held)
|
||||
operations = [runtime.connect_claude_vscode, runtime.complete_claude_onboarding]
|
||||
if first == "complete":
|
||||
operations.reverse()
|
||||
writer = asyncio.create_task(operations[0]())
|
||||
second = reader = None
|
||||
try:
|
||||
await asyncio.wait_for(entered.wait(), 5)
|
||||
second = asyncio.create_task(operations[1]())
|
||||
reader = asyncio.create_task(runtime.claude_onboarding_status())
|
||||
assert (await asyncio.wait_for(runtime.jetbrains_acp_status(), 1))[
|
||||
"connected"
|
||||
] is False
|
||||
assert not second.done() and not reader.done()
|
||||
assert calls == 1
|
||||
release.set()
|
||||
await asyncio.wait_for(asyncio.gather(writer, second), 5)
|
||||
assert (await asyncio.wait_for(reader, 5))["completed"] is True
|
||||
assert calls == 2
|
||||
assert json.loads(path.read_text()) == {
|
||||
"theme": "dark",
|
||||
"hasCompletedOnboarding": True,
|
||||
}
|
||||
assert (await runtime.claude_vscode_status())["connected"] is True
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(
|
||||
writer,
|
||||
*([second] if second else []),
|
||||
*([reader] if reader else []),
|
||||
return_exceptions=True,
|
||||
)
|
||||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_onboarding_write_is_drained_before_shutdown(
|
||||
runtime, monkeypatch
|
||||
):
|
||||
entered, release = asyncio.Event(), threading.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
complete = claude_onboarding.complete
|
||||
|
||||
def held(path):
|
||||
loop.call_soon_threadsafe(entered.set)
|
||||
assert release.wait(5)
|
||||
return complete(path)
|
||||
|
||||
monkeypatch.setattr(claude_onboarding, "complete", held)
|
||||
writer = asyncio.create_task(runtime.complete_claude_onboarding())
|
||||
close = None
|
||||
try:
|
||||
await asyncio.wait_for(entered.wait(), 5)
|
||||
writer.cancel()
|
||||
close = asyncio.create_task(runtime.close())
|
||||
await asyncio.sleep(0)
|
||||
assert not writer.done() and not close.done()
|
||||
assert runtime._config_lock.locked()
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await asyncio.wait_for(writer, 5)
|
||||
assert await asyncio.wait_for(close, 5)
|
||||
assert (
|
||||
json.loads(claude_onboarding.state_path().read_text())[
|
||||
"hasCompletedOnboarding"
|
||||
]
|
||||
is True
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(
|
||||
writer, *([close] if close else []), return_exceptions=True
|
||||
)
|
||||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continuously_changed_generation_has_bounded_retry(runtime, monkeypatch):
|
||||
entered, release = asyncio.Queue(), threading.Semaphore(0)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def held(path, state_path, url, token, connected=None):
|
||||
def held(path, url, token, connected=None):
|
||||
loop.call_soon_threadsafe(entered.put_nowait, token)
|
||||
assert release.acquire(timeout=5)
|
||||
return {"connected": False, "paths": {}}
|
||||
|
|
@ -257,7 +355,7 @@ async def test_generation_change_discards_old_status_outcome(
|
|||
tokens = []
|
||||
initial_token = runtime.settings.proxy_auth_token
|
||||
|
||||
def held(path, state_path, url, token, connected=None):
|
||||
def held(path, url, token, connected=None):
|
||||
tokens.append(token)
|
||||
if len(tokens) == 1:
|
||||
loop.call_soon_threadsafe(entered.set)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue