diff --git a/ui/desktop/.gitignore b/ui/desktop/.gitignore index b537c5e2..696aa48a 100644 --- a/ui/desktop/.gitignore +++ b/ui/desktop/.gitignore @@ -3,3 +3,5 @@ node_modules out src/bin/goosed /src/bin/goosed.exe +/playwright-report/ +/test-results/ diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index faf8479f..b7dabc7b 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -62,6 +62,7 @@ "@electron/fuses": "^1.8.0", "@eslint/js": "^8.56.0", "@hey-api/openapi-ts": "^0.64.4", + "@playwright/test": "^1.51.1", "@tailwindcss/typography": "^0.5.15", "@types/cors": "^2.8.17", "@types/electron-squirrel-startup": "^1.0.2", @@ -2504,6 +2505,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", + "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/colors": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", @@ -13430,6 +13447,53 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", + "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 038cf7fd..f3da8047 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -15,7 +15,10 @@ "bundle:windows": "node scripts/build-main.js && npm run make -- --platform=win32 --arch=x64 && node scripts/copy-windows-dlls.js", "bundle:intel": "npm run make -- --arch=x64 && cd out/Goose-darwin-x64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose_intel_mac.zip", "debug": "echo 'run --remote-debugging-port=8315' && lldb out/Goose-darwin-arm64/Goose.app", - "test-e2e": "electron-forge start > /tmp/out.txt & ELECTRON_PID=$! && sleep 12 && if grep -q 'renderer: ChatWindow loaded' /tmp/out.txt; then echo 'process is running'; pkill -f electron; else echo 'not starting correctly'; cat /tmp/out.txt; pkill -f electron; exit 1; fi", + "test-e2e": "npm run generate-api && playwright test", + "test-e2e:ui": "npm run generate-api && playwright test --ui", + "test-e2e:debug": "npm run generate-api && playwright test --debug", + "test-e2e:report": "playwright show-report", "lint": "eslint \"src/**/*.{ts,tsx}\" --fix", "lint:check": "eslint \"src/**/*.{ts,tsx}\"", "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", @@ -35,6 +38,7 @@ "@electron/fuses": "^1.8.0", "@eslint/js": "^8.56.0", "@hey-api/openapi-ts": "^0.64.4", + "@playwright/test": "^1.51.1", "@tailwindcss/typography": "^0.5.15", "@types/cors": "^2.8.17", "@types/electron-squirrel-startup": "^1.0.2", diff --git a/ui/desktop/playwright.config.ts b/ui/desktop/playwright.config.ts new file mode 100644 index 00000000..2923eddb --- /dev/null +++ b/ui/desktop/playwright.config.ts @@ -0,0 +1,26 @@ +import { PlaywrightTestConfig } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + testDir: './tests/e2e', + timeout: 60000, + expect: { + timeout: 30000 + }, + fullyParallel: false, + workers: 1, + reporter: [ + ['html'], + ['list'] + ], + use: { + actionTimeout: 30000, + navigationTimeout: 30000, + trace: 'on-first-retry', + video: 'retain-on-failure', + screenshot: 'only-on-failure' + }, + outputDir: 'test-results', + preserveOutput: 'failures-only' +}; + +export default config; \ No newline at end of file diff --git a/ui/desktop/tests/e2e/app.spec.ts b/ui/desktop/tests/e2e/app.spec.ts new file mode 100644 index 00000000..565b0eac --- /dev/null +++ b/ui/desktop/tests/e2e/app.spec.ts @@ -0,0 +1,253 @@ +import { test, expect } from '@playwright/test'; +import { _electron as electron } from '@playwright/test'; +import { join } from 'path'; +import { spawn, exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +test.describe('Goose App', () => { + let electronApp; + let appProcess; + let mainWindow; + let isProviderSelected = false; + + test.beforeAll(async () => { + console.log('Starting Electron app...'); + + // Start the electron-forge process + appProcess = spawn('npm', ['run', 'start-gui'], { + cwd: join(__dirname, '../..'), + stdio: 'pipe', + shell: true, + env: { + ...process.env, + ELECTRON_IS_DEV: '1', + NODE_ENV: 'development' + } + }); + + // Log process output + appProcess.stdout.on('data', (data) => { + console.log('App stdout:', data.toString()); + }); + + appProcess.stderr.on('data', (data) => { + console.log('App stderr:', data.toString()); + }); + + // Wait a bit for the app to start + console.log('Waiting for app to start...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + + // Launch Electron for testing + electronApp = await electron.launch({ + args: ['.vite/build/main.js'], + cwd: join(__dirname, '../..'), + env: { + ...process.env, + ELECTRON_IS_DEV: '1', + NODE_ENV: 'development' + } + }); + + // Get the main window once for all tests + mainWindow = await electronApp.firstWindow(); + await mainWindow.waitForLoadState('domcontentloaded'); + + // Check if we're already on the chat screen + try { + const chatInput = await mainWindow.waitForSelector('textarea[placeholder*="What can goose help with?"]', + { timeout: 5000 }); + isProviderSelected = await chatInput.isVisible(); + console.log('Provider already selected, chat interface visible'); + } catch (e) { + console.log('On provider selection screen'); + isProviderSelected = false; + } + }); + + test.afterAll(async () => { + console.log('Final cleanup...'); + + // Close the test instance + if (electronApp) { + await electronApp.close().catch(console.error); + } + + // Kill any remaining electron processes + try { + if (process.platform === 'win32') { + await execAsync('taskkill /F /IM electron.exe'); + } else { + await execAsync('pkill -f electron || true'); + } + } catch (error) { + if (!error.message?.includes('no process found')) { + console.error('Error killing electron processes:', error); + } + } + + // Kill any remaining npm processes from start-gui + try { + if (process.platform === 'win32') { + await execAsync('taskkill /F /IM node.exe'); + } else { + await execAsync('pkill -f "start-gui" || true'); + } + } catch (error) { + if (!error.message?.includes('no process found')) { + console.error('Error killing npm processes:', error); + } + } + + // Kill the specific npm process if it's still running + try { + if (appProcess && appProcess.pid) { + process.kill(appProcess.pid); + } + } catch (error) { + if (error.code !== 'ESRCH') { + console.error('Error killing npm process:', error); + } + } + }); + + test('verify initial screen and select provider if needed', async () => { + console.log('Checking initial screen state...'); + + if (!isProviderSelected) { + // Take screenshot of provider selection screen + await mainWindow.screenshot({ path: 'test-results/provider-selection.png' }); + + // Verify provider selection screen + const heading = await mainWindow.waitForSelector('h2:has-text("Choose a Provider")', { timeout: 10000 }); + const headingText = await heading.textContent(); + expect(headingText).toBe('Choose a Provider'); + + // Find and verify the Databricks card container + console.log('Looking for Databricks card...'); + const databricksContainer = await mainWindow.waitForSelector( + 'div:has(h3:text("Databricks"))[class*="relative bg-bgApp rounded-lg"]' + ); + expect(await databricksContainer.isVisible()).toBe(true); + + // Find the Launch button within the Databricks container + console.log('Looking for Launch button in Databricks card...'); + const launchButton = await databricksContainer.waitForSelector('button:has-text("Launch")'); + expect(await launchButton.isVisible()).toBe(true); + + // Take screenshot before clicking + await mainWindow.screenshot({ path: 'test-results/before-databricks-click.png' }); + + // Click the Launch button + await launchButton.click(); + + // Wait for chat interface to appear + const chatTextarea = await mainWindow.waitForSelector('textarea[placeholder*="What can goose help with?"]', + { timeout: 15000 }); + expect(await chatTextarea.isVisible()).toBe(true); + } else { + console.log('Provider already selected, skipping provider selection test'); + } + + // Take screenshot of current state + await mainWindow.screenshot({ path: 'test-results/chat-interface.png' }); + }); + + test('chat interaction', async () => { + console.log('Testing chat interaction...'); + + // Find the chat input + const chatInput = await mainWindow.waitForSelector('textarea[placeholder*="What can goose help with?"]'); + expect(await chatInput.isVisible()).toBe(true); + + // Type a message + await chatInput.fill('Hello, can you help me with a simple task?'); + + // Take screenshot before sending + await mainWindow.screenshot({ path: 'test-results/before-send.png' }); + + // Get initial message count + const initialMessages = await mainWindow.locator('.prose').count(); + + // Send message + await chatInput.press('Enter'); + + // Wait for loading indicator to appear (using the specific class and text) + console.log('Waiting for loading indicator...'); + const loadingGoose = await mainWindow.waitForSelector('.text-textStandard >> text="goose is working on it…"', + { timeout: 10000 }); + expect(await loadingGoose.isVisible()).toBe(true); + + // Take screenshot of loading state + await mainWindow.screenshot({ path: 'test-results/loading-state.png' }); + + // Wait for loading indicator to disappear + console.log('Waiting for response...'); + await mainWindow.waitForSelector('.text-textStandard >> text="goose is working on it…"', + { state: 'hidden', timeout: 30000 }); + + // Wait for new message to appear + await mainWindow.waitForFunction((count) => { + const messages = document.querySelectorAll('.prose'); + return messages.length > count; + }, initialMessages, { timeout: 30000 }); + + // Get the latest response + const response = await mainWindow.locator('.prose').last(); + expect(await response.isVisible()).toBe(true); + + // Verify response has content + const responseText = await response.textContent(); + expect(responseText).toBeTruthy(); + expect(responseText.length).toBeGreaterThan(0); + + // Take screenshot of response + await mainWindow.screenshot({ path: 'test-results/chat-response.png' }); + }); + + test('verify chat history', async () => { + console.log('Testing chat history...'); + + // Find the chat input again + const chatInput = await mainWindow.waitForSelector('textarea[placeholder*="What can goose help with?"]'); + + // Test message sending with a specific question + await chatInput.fill('What is 2+2?'); + + // Get initial message count + const initialMessages = await mainWindow.locator('.prose').count(); + + // Send message + await chatInput.press('Enter'); + + // Wait for loading indicator and response using the correct selector + await mainWindow.waitForSelector('.text-textStandard >> text="goose is working on it…"', { timeout: 10000 }); + await mainWindow.waitForSelector('.text-textStandard >> text="goose is working on it…"', + { state: 'hidden', timeout: 30000 }); + + // Wait for new message + await mainWindow.waitForFunction((count) => { + const messages = document.querySelectorAll('.prose'); + return messages.length > count; + }, initialMessages, { timeout: 30000 }); + + // Get the latest response + const response = await mainWindow.locator('.prose').last(); + const responseText = await response.textContent(); + expect(responseText).toBeTruthy(); + + // Check for message history + const messages = await mainWindow.locator('.prose').all(); + expect(messages.length).toBeGreaterThanOrEqual(2); + + // Take screenshot of chat history + await mainWindow.screenshot({ path: 'test-results/chat-history.png' }); + + // Test command history (up arrow) + await chatInput.press('Control+ArrowUp'); + const inputValue = await chatInput.inputValue(); + expect(inputValue).toBe('What is 2+2?'); + }); +}); \ No newline at end of file