diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index e2a0b1cac..4e1f7a6f6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -49,29 +49,22 @@ jobs: fetch-depth: 0 ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 with: - node-version: '22' - cache: 'npm' + bun-version: latest - name: Install dependencies - run: npm ci + run: bun install - name: Run security audit - run: npm audit --audit-level=moderate - - - name: Check for known vulnerabilities - run: npx audit-ci --moderate + run: bun audit # Code quality checks code-quality: name: Code Quality & Standards runs-on: ubuntu-latest timeout-minutes: 15 - strategy: - matrix: - node-version: [20, 22] steps: - name: Checkout code @@ -81,42 +74,25 @@ jobs: ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: oven-sh/setup-bun@v1 with: node-version: ${{ matrix.node-version }} - cache: 'npm' - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node-${{ matrix.node-version }}- - name: Install dependencies - run: npm ci - - - name: Assert package-lock.json is correct - run: | - if ! git diff --quiet; then - echo 'Package-lock.json file needs to be updated' - git diff - exit 1 - fi + run: bun install - name: Run TypeScript type checking - run: npm run type-check + run: bun run type-check - name: Run ESLint - run: npm run lint + run: bun run lint - name: Check code formatting with Prettier - run: npx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css,md}" + run: bunx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css,md}" - name: Run complexity analysis run: | - npx typescript-complexity-analyzer src/ + bunx typescript-complexity-analyzer src/ continue-on-error: true # Build verification across environments @@ -135,21 +111,20 @@ jobs: with: ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 with: - node-version: '22' - cache: 'npm' + bun-version: latest - name: Install dependencies - run: npm ci + run: bun install - name: Build application (${{ matrix.build-mode }}) run: | if [ "${{ matrix.build-mode }}" = "development" ]; then - npm run build:dev + bun run build:dev else - npm run build + bun run build fi - name: Verify build artifacts @@ -213,29 +188,19 @@ jobs: with: ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 with: - node-version: '22' - cache: 'npm' - + bun-version: latest + - name: Install dependencies - run: npm ci + run: bun install - name: Run unit tests - run: npm run test:run -- --coverage --reporter=verbose + run: bun run test:run --coverage --reporter=verbose - name: Run component tests - run: npm run test:run -- --run --coverage - - - name: Generate detailed coverage report - run: npm run coverage - - - name: Check coverage thresholds - run: | - # Set minimum coverage thresholds - npx nyc check-coverage --lines 80 --functions 80 --branches 75 --statements 80 - continue-on-error: true + run: bun run test:run --coverage - name: Upload coverage reports uses: codecov/codecov-action@v4 @@ -243,7 +208,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage/lcov.info flags: unittests - name: codecov-polyglut + name: codecov-polyglot fail_ci_if_error: false - name: Upload coverage to Coveralls @@ -268,30 +233,29 @@ jobs: with: ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 with: - node-version: '22' - cache: 'npm' + bun-version: latest - name: Install dependencies - run: npm ci + run: bun install - name: Install Playwright browsers - run: npx playwright install --with-deps + run: bunx playwright install --with-deps - name: Build application - run: npm run build + run: bun run build - name: Start preview server - run: npm run preview & + run: bun run preview & - name: Wait for server run: | timeout 60 bash -c 'until curl -f http://localhost:4173; do sleep 2; done' - name: Run E2E tests - run: npx playwright test + run: bunx playwright test - name: Upload E2E test results uses: actions/upload-artifact@v4 @@ -314,27 +278,26 @@ jobs: with: ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 with: - node-version: '22' - cache: 'npm' + bun-version: latest - name: Install dependencies - run: npm ci + run: bun install - name: Build application - run: npm run build + run: bun run build - name: Serve application - run: npm run preview & + run: bun run preview & - name: Wait for server run: sleep 10 - name: Run Lighthouse CI run: | - npm install -g @lhci/cli@0.13.x + bun add -g @lhci/cli@0.13.x lhci autorun env: LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} @@ -359,21 +322,20 @@ jobs: with: ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 with: - node-version: '22' - cache: 'npm' + bun-version: latest - name: Install dependencies - run: npm ci + run: bun install - name: Build with bundle analysis - run: npm run build + run: bun run build - name: Analyze bundle size run: | - npx vite-bundle-analyzer dist/assets/*.js --mode=static --report-filename=bundle-report.html + bunx vite-bundle-analyzer dist/assets/*.js --mode=static --report-filename=bundle-report.html continue-on-error: true - name: Upload bundle analysis diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 4eb64cdf3..1cf44c48c 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -23,29 +23,25 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 with: - node-version: '20.x' - cache: 'npm' + bun-version: latest - name: Install dependencies - run: npm ci + run: bun install - name: Run type check - run: npm run type-check + run: bun run type-check - name: Run linter - run: npm run lint + run: bun run lint - name: Run tests - run: npm run test:run - - - name: Generate coverage - run: npm run coverage + run: bun run test:run --coverage - name: Build application - run: npm run build + run: bun run build - name: Upload coverage to Codecov (optional) uses: codecov/codecov-action@v3 diff --git a/package.json b/package.json index abb3d0712..20f2145b1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "tsc --noEmit && vite build", "build:ci": "npm run build && npm run test:ci", "build:dev": "vite build --mode development", "lint": "eslint . --cache --max-warnings 0", @@ -18,6 +18,7 @@ "test:watch": "vitest --watch", "test:run": "vitest run", "test:ci": "vitest run --coverage --passWithNoTests", + "type-check": "tsc --noEmit", "ingest-rag": "node --loader ts-node/esm src/scripts/ingestRagFolder.ts" }, "dependencies": { diff --git a/src/__tests__/services/mcpService.test.ts b/src/__tests__/services/mcpService.test.ts new file mode 100644 index 000000000..033f5b1f1 --- /dev/null +++ b/src/__tests__/services/mcpService.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +// Small helper to delay +const tick = (ms = 10) => new Promise(resolve => setTimeout(resolve, ms)) + +describe('mcpService discovery and prompt generation', () => { + beforeEach(() => { + // Reset module cache so each test gets a fresh mcpService instance + vi.resetModules() + + // Mock fetch to serve a config with two servers + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + servers: [ + { name: 'day-server', description: 'Provides current day', url: 'ws://localhost:9001' }, + { name: 'email-tool', description: 'Local email tool (Gmail)', url: 'ws://localhost:3000' } + ] + }) + }) as any + + // Mock WebSocket to simulate MCP servers + class MockWebSocket { + url: string + onopen: ((ev?: any) => void) | null = null + onmessage: ((ev: { data: string }) => void) | null = null + onerror: ((err: any) => void) | null = null + sent: any[] = [] + + constructor(url: string) { + this.url = url + // call onopen shortly after the consumer attaches handlers + setTimeout(() => { + if (this.onopen) this.onopen() + }, 0) + } + + send(data: string) { + this.sent.push(data) + try { + const msg = JSON.parse(data) + if (msg.method === 'tools/list') { + // Return different tool lists depending on URL + const tools = this.url.includes('9001') + ? [{ name: 'get_day', description: 'Returns current day of the week' }] + : [{ name: 'send_email', description: 'Send an email via SMTP' }] + + const response = { + jsonrpc: '2.0', + id: msg.id, + result: { tools } + } + + setTimeout(() => { + if (this.onmessage) this.onmessage({ data: JSON.stringify(response) }) + }, 0) + } + } catch (e) { + // ignore + } + } + + // no-op + addEventListener() {} + removeEventListener() {} + } + + // @ts-ignore - replace global WebSocket + global.WebSocket = MockWebSocket as any + }) + + afterEach(() => { + vi.restoreAllMocks() + // @ts-ignore + delete (global as any).WebSocket + // @ts-ignore + delete (global as any).fetch + }) + + it('discovers both configured servers and builds a structured cached prompt', async () => { + const mod = await import('@/services/mcpService') + const { mcpService } = mod + + // initialize will attempt to fetch config and connect to servers + await mcpService.initialize() + + // Wait a short while for async discovery to complete + await tick(50) + + const tools = mcpService.getAvailableTools() + const toolNames = tools.map(t => t.name) + + expect(toolNames).toContain('get_day') + expect(toolNames).toContain('send_email') + + const prompt = mcpService.getCachedSystemPrompt() + expect(prompt).toBeTruthy() + expect(prompt).toContain('send_email') + expect(prompt).toContain('tool_call') + expect(prompt).toContain('Example (send an email)') + }) +}) diff --git a/src/services/indexedDbStorage.ts b/src/services/indexedDbStorage.ts index 5d73789e4..8e0fcf409 100644 --- a/src/services/indexedDbStorage.ts +++ b/src/services/indexedDbStorage.ts @@ -91,8 +91,9 @@ export class IndexedDbStorage { private prepareChatForStorage(chat: Chat): Chat { const now = new Date(); - // Don't Filter out private messages because this is how they persist in main UI - // const filteredMessages = (chat.messages || []).filter(msg => !msg.isPrivate); + // Filter out private messages before persisting to IndexedDB/localStorage. + // Private messages (msg.isPrivate === true) must never be saved client-side. + const filteredMessages = (chat.messages || []).filter((msg) => !msg.isPrivate); return { ...chat, @@ -102,7 +103,7 @@ export class IndexedDbStorage { lastModified: now, isArchived: chat.isArchived || false, currentModel: chat.currentModel || chat.model || "unknown", - messages: (chat.messages || []).map((msg) => ({ + messages: filteredMessages.map((msg) => ({ ...msg, id: msg.id || crypto.randomUUID(), timestamp: msg.timestamp || now, @@ -272,7 +273,28 @@ export class IndexedDbStorage { async saveConversation(conversation: Chat): Promise { try { const preparedConversation = this.prepareChatForStorage(conversation); - + + // If after filtering private messages there are no messages left, do not persist. + // If this conversation previously existed in the DB, remove it so it doesn't show in the sidebar. + if (!preparedConversation.messages || preparedConversation.messages.length === 0) { + if (conversation.id) { + try { + await this.db.chats.delete(conversation.id); + } catch (delErr) { + console.warn('Failed to delete empty/private-only conversation locally:', delErr); + } + // Also attempt to delete remotely to keep server in sync + try { + await fetch(`http://localhost:4001/deleteChat/${conversation.id}`, { method: 'DELETE' }); + } catch (e) { + // ignore remote delete failures + } + } + + // Nothing to persist locally + return; + } + // Save to IndexedDB await this.db.chats.put(preparedConversation);