refactor(conversation-assistant): migrate settings to webapp

- Delete SettingsView.swift - settings now in webapp modal
- Add updateBaseURL method to APIClient for dynamic URL changes
- Update ConversationAssistantApp to use EmptyView for Settings scene
- Add comprehensive E2E tests for settings modal functionality
  - Open/close settings modal
  - Save settings
  - Reset data confirmation flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-30 04:56:16 -08:00
parent 5339875f80
commit d70dbd9920
5 changed files with 222 additions and 79 deletions

View file

@ -104,6 +104,46 @@ export const mockApi = {
needsFullDiskAccess: true,
});
},
/**
* Mock settings endpoint
*/
async settings(page: any, settings: Partial<SettingsResponse> = {}) {
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;
}

View file

@ -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);
});
});

View file

@ -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()
}
}
}

View file

@ -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"

View file

@ -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()
}