diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 175a66e0..36268ec7 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -64,6 +64,7 @@ "@electron/fuses": "^1.8.0", "@eslint/js": "^8.56.0", "@hey-api/openapi-ts": "^0.64.4", + "@modelcontextprotocol/sdk": "^1.8.0", "@playwright/test": "^1.51.1", "@tailwindcss/typography": "^0.5.15", "@types/cors": "^2.8.17", @@ -2424,6 +2425,312 @@ "node": ">= 12.13.0" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.8.0.tgz", + "integrity": "sha512-e06W7SwrontJDHwCawNO5SGxG+nU9AAx+jpHHZqGl/WrDBdWOpvirC+s58VpJTB5QemI4jTRcjWT4Pt3Q1NPQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.3", + "eventsource": "^3.0.2", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^4.1.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -8168,6 +8475,19 @@ "dev": true, "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", + "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/eventsource-parser": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz", @@ -8177,6 +8497,16 @@ "node": ">=14.18" } }, + "node_modules/eventsource/node_modules/eventsource-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", + "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8267,6 +8597,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, "node_modules/express-ws": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz", @@ -10190,6 +10536,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -13435,6 +13788,16 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", + "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -14856,6 +15219,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -17533,7 +17923,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ui/desktop/package.json b/ui/desktop/package.json index d9125a67..0cf0a291 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -38,6 +38,7 @@ "@electron/fuses": "^1.8.0", "@eslint/js": "^8.56.0", "@hey-api/openapi-ts": "^0.64.4", + "@modelcontextprotocol/sdk": "^1.8.0", "@playwright/test": "^1.51.1", "@tailwindcss/typography": "^0.5.15", "@types/cors": "^2.8.17", diff --git a/ui/desktop/tests/e2e/app.spec.ts b/ui/desktop/tests/e2e/app.spec.ts index 565b0eac..3463aacf 100644 --- a/ui/desktop/tests/e2e/app.spec.ts +++ b/ui/desktop/tests/e2e/app.spec.ts @@ -1,20 +1,131 @@ -import { test, expect } from '@playwright/test'; +import { test as base, 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'; +import { showTestName, clearTestName } from './test-overlay'; +const { runningQuotes } = require('./basic-mcp'); const execAsync = promisify(exec); +// Define provider interface +type Provider = { + name: string; + testPath: string; +}; + +// Create test fixture type +type TestFixtures = { + provider: Provider; +}; + +// Define available providers +const providers: Provider[] = [ + { name: 'Databricks', testPath: 'div:has(h3:text("Databricks"))[class*="relative bg-bgApp rounded-lg"]' }, + { name: 'Google', testPath: 'div:has(h3:text("Google"))[class*="relative bg-bgApp rounded-lg"]' } +]; + +// Create test with fixtures +const test = base.extend({ + provider: [providers[0], { option: true }], // Default to first provider (Databricks) +}); + +// Store mainWindow reference +let mainWindow; + +// Add hooks for test name overlay +// eslint-disable-next-line no-empty-pattern +test.beforeEach(async ({ }, testInfo) => { + if (mainWindow) { + // Get a clean test name without the full hierarchy + const testName = testInfo.titlePath[testInfo.titlePath.length - 1]; + + // Get provider name if we're in a provider suite + const providerSuite = testInfo.titlePath.find(t => t.startsWith('Provider:')); + const providerName = providerSuite ? providerSuite.split(': ')[1] : undefined; + + console.log(`Setting overlay for test: "${testName}"${providerName ? ` (Provider: ${providerName})` : ''}`); + await showTestName(mainWindow, testName, providerName); + } +}); + +test.afterEach(async () => { + if (mainWindow) { + await clearTestName(mainWindow); + } +}); + +// Helper function to select a provider +async function selectProvider(mainWindow: any, provider: Provider) { + console.log(`Selecting provider: ${provider.name}`); + + // Click the menu button (3 dots) if we're in chat + try { + // Wait for header and menu button to be visible + await mainWindow.waitForSelector('div[class*="bg-bgSubtle border-b border-borderSubtle"]', { timeout: 5000 }); + await mainWindow.waitForTimeout(1000); // Give UI time to stabilize + + const menuButton = await mainWindow.waitForSelector('button:has(svg)', { + timeout: 5000, + state: 'visible' + }); + await menuButton.click(); + + // Wait for menu to be visible + await mainWindow.waitForTimeout(1000); + + // Click "Reset provider and model" + const resetProviderButton = await mainWindow.waitForSelector('button:has-text("Reset provider and model")', { timeout: 5000 }); + await resetProviderButton.click(); + + // Wait for page to start refreshing + await mainWindow.waitForTimeout(1000); + + // Wait for page to finish reloading + await mainWindow.reload(); + await mainWindow.waitForLoadState('networkidle'); + + } catch (e) { + console.log('Already on provider selection screen or error:', e); + } + + // Wait for 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 provider card container + console.log(`Looking for ${provider.name} card...`); + const providerContainer = await mainWindow.waitForSelector(provider.testPath); + expect(await providerContainer.isVisible()).toBe(true); + + // Find the Launch button within the provider container + console.log(`Looking for Launch button in ${provider.name} card...`); + const launchButton = await providerContainer.waitForSelector('button:has-text("Launch")'); + expect(await launchButton.isVisible()).toBe(true); + + // Take screenshot before clicking + await mainWindow.screenshot({ path: `test-results/before-${provider.name.toLowerCase()}-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); + + // Take screenshot of chat interface + await mainWindow.screenshot({ path: `test-results/chat-interface-${provider.name.toLowerCase()}.png` }); +} + 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, '../..'), @@ -48,28 +159,21 @@ test.describe('Goose App', () => { ...process.env, ELECTRON_IS_DEV: '1', NODE_ENV: 'development' + }, + recordVideo: { + dir: 'test-results/videos/', + size: { width: 620, height: 680 } } }); // 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); @@ -113,141 +217,312 @@ test.describe('Goose App', () => { } }); - 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' }); + test.describe('General UI', () => { + test('dark mode toggle', async () => { + console.log('Testing dark mode toggle...'); + + // Click the three dots menu button in the top right + await mainWindow.waitForSelector('div[class*="bg-bgSubtle border-b border-borderSubtle"]'); + const menuButton = await mainWindow.waitForSelector('button:has(svg)', { timeout: 10000 }); + await menuButton.click(); + + // Find and click the dark mode toggle button + const darkModeButton = await mainWindow.waitForSelector('button:has-text("Dark Mode"), button:has-text("Light Mode")'); + + // Get initial state + const isDarkMode = await mainWindow.evaluate(() => document.documentElement.classList.contains('dark')); + console.log('Initial dark mode state:', isDarkMode); + + // Click to toggle + await darkModeButton.click(); + + // Verify the change + const newDarkMode = await mainWindow.evaluate(() => document.documentElement.classList.contains('dark')); + expect(newDarkMode).toBe(!isDarkMode); + + // Take screenshot to verify and pause to show the change + await mainWindow.screenshot({ path: 'test-results/dark-mode-toggle.png' }); + await mainWindow.waitForTimeout(2000); // Pause in dark/light mode + + // Toggle back to original state + await darkModeButton.click(); - // 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' }); + // Pause to show return to original state + await mainWindow.waitForTimeout(2000); + + // Close menu with ESC key + await mainWindow.keyboard.press('Escape'); + }); }); - 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' }); - }); + for (const provider of providers) { + test.describe(`Provider: ${provider.name}`, () => { + test.beforeAll(async () => { + // Select the provider once before all tests for this provider + await selectProvider(mainWindow, provider); + }); - test('verify chat history', async () => { - console.log('Testing chat history...'); + test.describe('Chat', () => { + test('chat interaction', async () => { + console.log(`Testing chat interaction with ${provider.name}...`); - // Find the chat input again - const chatInput = await mainWindow.waitForSelector('textarea[placeholder*="What can goose help with?"]'); + // Find the chat input + const chatInput = await mainWindow.waitForSelector('textarea[placeholder*="What can goose help with?"]'); + expect(await chatInput.isVisible()).toBe(true); - // Test message sending with a specific question - await chatInput.fill('What is 2+2?'); + // Type a message + await chatInput.fill('Hello, can you help me with a simple task?'); - // Get initial message count - const initialMessages = await mainWindow.locator('.prose').count(); + // Take screenshot before sending + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-before-send.png` }); - // Send message - await chatInput.press('Enter'); + // Get initial message count + const initialMessages = await mainWindow.locator('.prose').count(); - // 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 }); + // Send message + await chatInput.press('Enter'); - // Wait for new message - await mainWindow.waitForFunction((count) => { - const messages = document.querySelectorAll('.prose'); - return messages.length > count; - }, initialMessages, { timeout: 30000 }); + // Wait for loading indicator to appear + 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); - // Get the latest response - const response = await mainWindow.locator('.prose').last(); - const responseText = await response.textContent(); - expect(responseText).toBeTruthy(); + // Take screenshot of loading state + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-loading-state.png` }); - // Check for message history - const messages = await mainWindow.locator('.prose').all(); - expect(messages.length).toBeGreaterThanOrEqual(2); + // 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 }); - // Take screenshot of chat history - await mainWindow.screenshot({ path: 'test-results/chat-history.png' }); + // Wait for new message to appear + await mainWindow.waitForFunction((count) => { + const messages = document.querySelectorAll('.prose'); + return messages.length > count; + }, initialMessages, { timeout: 30000 }); - // 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 + // 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/${provider.name.toLowerCase()}-chat-response.png` }); + }); + + test('verify chat history', async () => { + console.log(`Testing chat history with ${provider.name}...`); + + // 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 + 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/${provider.name.toLowerCase()}-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?'); + }); + }); + + test.describe('MCP Integration', () => { + test('running quotes MCP server integration', async () => { + console.log(`Testing Running Quotes MCP server integration with ${provider.name}...`); + + // Clean up any existing running-quotes extensions from localStorage + await mainWindow.evaluate(() => { + const USER_SETTINGS_KEY = 'user_settings'; + const settings = JSON.parse(localStorage.getItem(USER_SETTINGS_KEY) || '{"extensions":[]}'); + + // Remove any running-quotes extensions + settings.extensions = settings.extensions.filter(ext => ext.id !== 'running-quotes'); + + // Save back to localStorage + localStorage.setItem(USER_SETTINGS_KEY, JSON.stringify(settings)); + + // Log the cleanup + console.log('Cleaned up existing running-quotes extensions'); + }); + + // Reload the page to ensure settings are fresh + await mainWindow.reload(); + await mainWindow.waitForLoadState('networkidle'); + + // Click the menu button (3 dots) + const menuButton = await mainWindow.waitForSelector('button:has(svg)', { timeout: 10000 }); + await menuButton.click(); + + // Click Advanced Settings + const advancedSettingsButton = await mainWindow.waitForSelector('button:has-text("Advanced Settings")'); + await advancedSettingsButton.click(); + + // Wait for settings page and take screenshot + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-mcp-settings-page.png` }); + + // Click Add Custom Extension button and wait for modal + const addExtensionButton = await mainWindow.waitForSelector('button:has-text("Add Custom Extension")'); + await addExtensionButton.click(); + + // Wait for modal and form to be fully rendered + await mainWindow.waitForSelector('form', { state: 'visible', timeout: 10000 }); + console.log('Form found, waiting for modal animation...'); + await mainWindow.waitForTimeout(1000); // Wait for modal animation + + try { + // Fill ID (find by label text) + console.log('Filling ID field...'); + await mainWindow.locator('label:has-text("ID *") + input[type="text"]').fill('running-quotes'); + + // Fill Name (find by label text) + console.log('Filling Name field...'); + await mainWindow.locator('label:has-text("Name *") + input[type="text"]').fill('Running Quotes'); + + // Fill Description (find by label text) + console.log('Filling Description field...'); + await mainWindow.locator('label:has-text("Description *") + input[type="text"]').fill('Inspirational running quotes MCP server'); + + // Fill Command (find by label text and placeholder) + console.log('Filling Command field...'); + const mcpScriptPath = join(__dirname, 'basic-mcp.ts'); + await mainWindow.locator('label:has-text("Command *") + input[placeholder="e.g. goosed mcp example"]') + .fill(`node ${mcpScriptPath}`); + + // Take screenshot of filled form + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-mcp-form-filled.png` }); + + // Add a delay to inspect the form + console.log('Waiting 5 seconds to inspect form...'); + await mainWindow.waitForTimeout(5000); + + // Click Add button (it's a submit button) + console.log('Clicking Add button...'); + await mainWindow.locator('button[type="submit"]').click(); + + // Wait for success toast and take screenshot + await mainWindow.waitForSelector('.Toastify__toast-body div div:has-text("Successfully enabled extension")', + { state: 'visible', timeout: 10000 }); + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-mcp-extension-added.png` }); + console.log('Extension added successfully'); + + // Click Exit button to return to chat + const exitButton = await mainWindow.waitForSelector('button:has-text("Exit")', { timeout: 5000 }); + await exitButton.click(); + + } catch (error) { + // Take error screenshot + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-mcp-form-error.png` }); + console.error('Error during form filling:', error); + throw error; + } + }); + + test('test running quotes functionality', async () => { + console.log(`Testing running quotes functionality with ${provider.name}...`); + + // 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 requesting a running quote + await chatInput.fill('Can you give me an inspirational running quote using the runningQuote tool?'); + + // Take screenshot before sending + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-before-quote-request.png` }); + + // Get initial message count + const initialMessages = await mainWindow.locator('.prose').count(); + + // Send message + await chatInput.press('Enter'); + + // Wait for loading indicator + const loadingIndicator = await mainWindow.waitForSelector('.text-textStandard >> text="goose is working on it…"', + { timeout: 10000 }); + expect(await loadingIndicator.isVisible()).toBe(true); + + // Take screenshot of loading state + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-quote-loading.png` }); + + // Wait for loading indicator to disappear + 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); + + // Click the Output dropdown to reveal the actual quote + const outputButton = await mainWindow.waitForSelector('button:has-text("Output")', { timeout: 5000 }); + await outputButton.click(); + + // Wait a bit and dump HTML to see structure + await mainWindow.waitForTimeout(1000); + const html = await mainWindow.evaluate(() => document.documentElement.outerHTML); + console.log('Full page HTML after clicking Output:', html); + + // Also dump just the response area HTML + const responseHtml = await response.evaluate(el => el.outerHTML); + console.log('Response area HTML:', responseHtml); + + // Take screenshot before trying to find content + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-quote-response-debug.png` }); + + // Now try to get the output content + const outputContent = await mainWindow.waitForSelector('.whitespace-pre-wrap', { timeout: 5000 }); + const outputText = await outputContent.textContent(); + console.log('Output text:', outputText); + + // Take screenshot of expanded response + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-quote-response.png` }); + + // Check if the output contains one of our known quotes + const containsKnownQuote = runningQuotes.some(({ quote, author }) => + outputText.includes(`"${quote}" - ${author}`) + ); + expect(containsKnownQuote).toBe(true); + }); + }); + }); + } +}); diff --git a/ui/desktop/tests/e2e/basic-mcp.ts b/ui/desktop/tests/e2e/basic-mcp.ts new file mode 100644 index 00000000..487c3691 --- /dev/null +++ b/ui/desktop/tests/e2e/basic-mcp.ts @@ -0,0 +1,71 @@ +const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); +const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js"); + +// Collection of running-related inspirational quotes +const runningQuotes = [ + { + quote: "The miracle isn't that I finished. The miracle is that I had the courage to start.", + author: "John Bingham" + }, + { + quote: "Running is the greatest metaphor for life, because you get out of it what you put into it.", + author: "Oprah Winfrey" + }, + { + quote: "Pain is temporary. Quitting lasts forever.", + author: "Lance Armstrong" + }, + { + quote: "Run when you can, walk if you have to, crawl if you must; just never give up.", + author: "Dean Karnazes" + }, + { + quote: "The only bad workout is the one that didn't happen.", + author: "Unknown" + }, + { + quote: "Whether you think you can or think you can't, you're right.", + author: "Henry Ford" + }, + { + quote: "If you want to run, run a mile. If you want to experience a different life, run a marathon.", + author: "Emil Zatopek" + }, + { + quote: "The voice inside your head that says you can't do this is a liar.", + author: "Unknown" + } +]; + +async function startServer() { + const server = new McpServer({ + name: "Running Quotes", + version: "1.0.0" + }); + + server.tool("runningQuote", + "Generates an inspirational running quote", + async () => { + const randomQuote = runningQuotes[Math.floor(Math.random() * runningQuotes.length)]; + return { + content: [{ + type: "text", + text: `"${randomQuote.quote}" - ${randomQuote.author}` + }] + }; + } + ); + + // Start receiving messages on stdin and sending messages on stdout + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +// Only start the server if this is the main module +if (require.main === module) { + startServer().catch(console.error); +} + +module.exports = { + runningQuotes +}; diff --git a/ui/desktop/tests/e2e/test-overlay.ts b/ui/desktop/tests/e2e/test-overlay.ts new file mode 100644 index 00000000..5494b966 --- /dev/null +++ b/ui/desktop/tests/e2e/test-overlay.ts @@ -0,0 +1,50 @@ +// Helper function to show test name overlay +async function showTestName(mainWindow: any, testName: string, providerName?: string) { + await mainWindow.evaluate(({ name, provider }: { name: string, provider?: string }) => { + // Remove any existing overlay + const existing = document.getElementById('test-overlay'); + if (existing) existing.remove(); + + // Create new overlay + const overlay = document.createElement('div'); + overlay.id = 'test-overlay'; + overlay.style.cssText = ` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 12px 16px; + border-radius: 6px; + font-family: monospace; + font-size: 14px; + z-index: 2147483647; // maximum z-index integer value + pointer-events: none; + text-align: center; + max-width: 80%; + white-space: pre-wrap; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + `; + + const testText = `Running: ${name}`; + const providerText = provider ? `\nProvider: ${provider}` : ''; + overlay.textContent = testText + providerText; + + // Insert at the beginning of to ensure it's above everything + document.documentElement.insertBefore(overlay, document.documentElement.firstChild); + + // Force a repaint to ensure the overlay is visible + overlay.getBoundingClientRect(); + }, { name: testName, provider: providerName }); +} + +// Helper function to clear test name overlay +async function clearTestName(mainWindow: any) { + await mainWindow.evaluate(() => { + const overlay = document.getElementById('test-overlay'); + if (overlay) overlay.remove(); + }); +} + +export { showTestName, clearTestName };