diff --git a/features/conversation-assistant/frontend-macos-client/e2e/fixture.ts b/features/conversation-assistant/frontend-macos-client/e2e/fixture.ts index 071ce05e2..c028e1a45 100644 --- a/features/conversation-assistant/frontend-macos-client/e2e/fixture.ts +++ b/features/conversation-assistant/frontend-macos-client/e2e/fixture.ts @@ -104,6 +104,46 @@ export const mockApi = { needsFullDiskAccess: true, }); }, + + /** + * Mock settings endpoint + */ + async settings(page: any, settings: Partial = {}) { + await page.route('**/api/settings', async (route: any) => { + const method = route.request().method(); + if (method === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + apiBaseURL: 'http://localhost:3100', + version: '0.0.1', + fullVersion: 'Version 0.0.1 (abc123)', + ...settings, + }), + }); + } else if (method === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + } + }); + }, + + /** + * Mock reset-data endpoint + */ + async resetData(page: any) { + await page.route('**/api/reset-data', async (route: any) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + }, }; interface StatusResponse { @@ -119,3 +159,9 @@ interface StatusResponse { isResetting: boolean; activityLog: Array<{ message: string; level: string; timestamp: string }>; } + +interface SettingsResponse { + apiBaseURL: string; + version: string; + fullVersion: string; +} diff --git a/features/conversation-assistant/frontend-macos-client/e2e/macos-client.spec.ts b/features/conversation-assistant/frontend-macos-client/e2e/macos-client.spec.ts index 8b759dd71..6ee18236e 100644 --- a/features/conversation-assistant/frontend-macos-client/e2e/macos-client.spec.ts +++ b/features/conversation-assistant/frontend-macos-client/e2e/macos-client.spec.ts @@ -226,3 +226,170 @@ test.describe('macOS Client - Footer', () => { expect(quitCalled).toBe(true); }); }); + +test.describe('macOS Client - Settings Modal', () => { + test('opens settings modal and displays current settings', async ({ page }) => { + await mockApi.status(page, { isAuthenticated: true }); + await mockApi.settings(page, { + apiBaseURL: 'https://api.example.com', + fullVersion: 'Version 1.2.3 (abc123)', + }); + + await page.goto('/'); + + // Click settings button + await page.locator('#btn-settings').click(); + + // Should show settings modal + await expect(page.locator('#settings-modal')).toBeVisible(); + + // Should display current API URL + await expect(page.locator('#input-api-url')).toHaveValue('https://api.example.com'); + + // Should display version in about section + await expect(page.locator('#about-version')).toHaveText('Version 1.2.3 (abc123)'); + }); + + test('closes settings modal with close button', async ({ page }) => { + await mockApi.status(page, { isAuthenticated: true }); + await mockApi.settings(page); + + await page.goto('/'); + + // Open settings + await page.locator('#btn-settings').click(); + await expect(page.locator('#settings-modal')).toBeVisible(); + + // Close with X button + await page.locator('#btn-close-settings').click(); + + // Should be hidden + await expect(page.locator('#settings-modal')).toBeHidden(); + }); + + test('closes settings modal with Escape key', async ({ page }) => { + await mockApi.status(page, { isAuthenticated: true }); + await mockApi.settings(page); + + await page.goto('/'); + + // Open settings + await page.locator('#btn-settings').click(); + await expect(page.locator('#settings-modal')).toBeVisible(); + + // Press Escape + await page.keyboard.press('Escape'); + + // Should be hidden + await expect(page.locator('#settings-modal')).toBeHidden(); + }); + + test('saves settings when Save Changes is clicked', async ({ page }) => { + let savedSettings: { apiBaseURL?: string } = {}; + + await mockApi.status(page, { isAuthenticated: true }); + + await page.route('**/api/settings', async (route) => { + const method = route.request().method(); + if (method === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + apiBaseURL: 'http://localhost:3100', + version: '0.0.1', + fullVersion: 'Version 0.0.1', + }), + }); + } else if (method === 'POST') { + const body = route.request().postDataJSON(); + savedSettings = body; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + } + }); + + await page.goto('/'); + + // Open settings + await page.locator('#btn-settings').click(); + + // Change API URL + await page.locator('#input-api-url').fill('https://new-api.example.com'); + + // Save + await page.locator('#btn-save-settings').click(); + + // Should have saved new URL + expect(savedSettings.apiBaseURL).toBe('https://new-api.example.com'); + + // Modal should close + await expect(page.locator('#settings-modal')).toBeHidden(); + }); + + test('shows reset confirmation modal', async ({ page }) => { + await mockApi.status(page, { isAuthenticated: true }); + await mockApi.settings(page); + + await page.goto('/'); + + // Open settings + await page.locator('#btn-settings').click(); + + // Click Reset All Data + await page.locator('#btn-reset-data').click(); + + // Should show reset confirmation modal + await expect(page.locator('#reset-modal')).toBeVisible(); + await expect(page.locator('#reset-modal h2')).toHaveText('Reset All Data?'); + }); + + test('cancels reset when Cancel is clicked', async ({ page }) => { + await mockApi.status(page, { isAuthenticated: true }); + await mockApi.settings(page); + + await page.goto('/'); + + // Open settings and click reset + await page.locator('#btn-settings').click(); + await page.locator('#btn-reset-data').click(); + + // Cancel + await page.locator('#btn-cancel-reset').click(); + + // Reset modal should close, settings modal should still be open + await expect(page.locator('#reset-modal')).toBeHidden(); + await expect(page.locator('#settings-modal')).toBeVisible(); + }); + + test('calls reset API when confirmed', async ({ page }) => { + let resetCalled = false; + + await mockApi.status(page, { isAuthenticated: true }); + await mockApi.settings(page); + + await page.route('**/api/reset-data', async (route) => { + resetCalled = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + + await page.goto('/'); + + // Open settings and click reset + await page.locator('#btn-settings').click(); + await page.locator('#btn-reset-data').click(); + + // Confirm reset + await page.locator('#btn-confirm-reset').click(); + + // Should have called reset API + expect(resetCalled).toBe(true); + }); +}); diff --git a/features/conversation-assistant/macos/Sources/ConversationAssistantApp.swift b/features/conversation-assistant/macos/Sources/ConversationAssistantApp.swift index e3078c91d..773b97a7f 100644 --- a/features/conversation-assistant/macos/Sources/ConversationAssistantApp.swift +++ b/features/conversation-assistant/macos/Sources/ConversationAssistantApp.swift @@ -6,8 +6,9 @@ struct ConversationAssistantApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { + // No SwiftUI scenes - app is menu bar only with webapp UI Settings { - SettingsView() + EmptyView() } } } diff --git a/features/conversation-assistant/macos/Sources/Services/APIClient.swift b/features/conversation-assistant/macos/Sources/Services/APIClient.swift index 7c9972902..165c1587c 100644 --- a/features/conversation-assistant/macos/Sources/Services/APIClient.swift +++ b/features/conversation-assistant/macos/Sources/Services/APIClient.swift @@ -5,7 +5,7 @@ import SwiftyJSON class APIClient { static let shared = APIClient() - private let baseURL: String + private var baseURL: String private var authToken: String? private var deviceId: String? @@ -24,6 +24,12 @@ class APIClient { NSLog("APIClient: init - baseURL: \(baseURL), deviceId: \(deviceId ?? "nil"), authToken: \(authToken != nil ? "present" : "nil")") } + /// Update the API base URL (used when settings change) + func updateBaseURL(_ newURL: String) { + baseURL = newURL + NSLog("APIClient: baseURL updated to \(newURL)") + } + func registerDevice() async throws -> (deviceId: String, code: String) { let hardwareId = getHardwareId() let deviceName = Host.current().localizedName ?? "Mac" diff --git a/features/conversation-assistant/macos/Sources/Views/SettingsView.swift b/features/conversation-assistant/macos/Sources/Views/SettingsView.swift deleted file mode 100644 index 5ac4d3131..000000000 --- a/features/conversation-assistant/macos/Sources/Views/SettingsView.swift +++ /dev/null @@ -1,77 +0,0 @@ -import SwiftUI - -struct SettingsView: View { - @AppStorage("apiBaseURL") private var apiBaseURL = "http://localhost:3100" - @State private var showingResetAlert = false - - var body: some View { - Form { - Section { - TextField("API Server URL", text: $apiBaseURL) - .textFieldStyle(.roundedBorder) - } header: { - Text("Server Configuration") - } footer: { - Text("The URL of the Conversation Assistant server") - .font(.caption) - .foregroundColor(.secondary) - } - - Section { - Button("Reset All Data", role: .destructive) { - showingResetAlert = true - } - } header: { - Text("Danger Zone") - } - - Section { - HStack { - Image(systemName: "bubble.left.and.bubble.right.fill") - .font(.title) - .foregroundColor(.accentColor) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text("Conversation Assistant") - .font(.headline) - Text(AppVersion.fullVersion) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - } - } header: { - Text("About") - } - } - .formStyle(.grouped) - .frame(width: 400, height: 280) - .alert("Reset All Data?", isPresented: $showingResetAlert) { - Button("Cancel", role: .cancel) {} - Button("Reset", role: .destructive) { - resetAllData() - } - } message: { - Text("This will remove all saved authentication and sync data. You will need to re-authenticate.") - } - } - - private func resetAllData() { - // Clear keychain - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword - ] - SecItemDelete(query as CFDictionary) - - // Clear user defaults - UserDefaults.standard.removeObject(forKey: "deviceId") - UserDefaults.standard.removeObject(forKey: "lastSync") - - // Restart app - NSApplication.shared.terminate(nil) - } -} - -#Preview { - SettingsView() -}