diff --git a/package-lock.json b/package-lock.json index 63f8368..df69e2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "jsdom": "^25.0.1", "node-mocks-http": "^1.16.2", "npm-run-all2": "^7.0.1", + "playwright": "^1.51.1", "prettier": "^3.0.1", "superagent": "^9.0.0", "supertest": "^7.0.0", @@ -11249,6 +11250,50 @@ "pathe": "^2.0.1" } }, + "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, + "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, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index c4609f9..a92c340 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "build-only": "vite build", "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", - "format": "prettier --write src/ server/" + "format": "prettier --write src/ server/", + "test:integration": "vitest run --config vitest.integration.config.js", + "test:integration:watch": "vitest --config vitest.integration.config.js", + "test:integration:ui": "vitest --ui --config vitest.integration.config.js" }, "dependencies": { "@element-plus/icons-vue": "^2.1.0", @@ -79,6 +82,7 @@ "jsdom": "^25.0.1", "node-mocks-http": "^1.16.2", "npm-run-all2": "^7.0.1", + "playwright": "^1.51.1", "prettier": "^3.0.1", "superagent": "^9.0.0", "supertest": "^7.0.0", diff --git a/src/components/ChatMessage.vue b/src/components/ChatMessage.vue index 239835d..3e4d526 100644 --- a/src/components/ChatMessage.vue +++ b/src/components/ChatMessage.vue @@ -83,7 +83,9 @@ export default {
- + + {{ message.args }} +
@@ -102,6 +104,17 @@ export default { max-width: min(600px, calc(100% - 60px)); } +.tool-message-container { + background-color: antiquewhite; + color:black; + padding: 10px; + margin: 10px 0 10px 0; + display: flex; + flex-direction: column; + width: fit-content; + max-width: min(600px, calc(100% - 60px)); +} + .user, .assistant { display: flex; flex-direction: column; diff --git a/src/models/ChatModel.ts b/src/models/ChatModel.ts index 7e318ee..a27de7a 100644 --- a/src/models/ChatModel.ts +++ b/src/models/ChatModel.ts @@ -1,8 +1,8 @@ -import type { OpeyMessage } from "./MessageModel"; +import type { OpeyMessage, AssistantMessage } from "./MessageModel"; export interface Chat { messages: OpeyMessage[]; - currentAssistantMessage: OpeyMessage; + currentAssistantMessage: AssistantMessage; status: 'ready' | 'streaming' | 'error' | 'loading'; userIsAuthenticated: boolean; threadId: string; diff --git a/src/models/MessageModel.ts b/src/models/MessageModel.ts index 87dfa8d..fc7b605 100644 --- a/src/models/MessageModel.ts +++ b/src/models/MessageModel.ts @@ -57,6 +57,7 @@ export interface UserMessage extends OpeyMessage { } export interface AssistantMessage extends OpeyMessage { + toolCalls: ToolMessage[]; // Probably we will need some fields here for tool call/ tool call approval requests } diff --git a/src/stores/chat.ts b/src/stores/chat.ts index eca6d44..df3ef2d 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -159,6 +159,7 @@ export const useChat = defineStore('chat', { content: '', role: 'assistant', id: uuidv4(), + toolCalls: [] } this.addMessage(this.currentAssistantMessage) @@ -184,13 +185,6 @@ export const useChat = defineStore('chat', { if (response.status !== 200) { throw new Error(`Error sending Opey message: ${response.statusText}`); } - - - let context = { - currentAssistantMessage: this.currentAssistantMessage, - messages: this.messages, - status: this.status - }; await this._processOpeyStream(stream); } catch (error) { @@ -233,19 +227,21 @@ export const useChat = defineStore('chat', { // This is where we process different types of messages from Opey by their 'type' field // Process pending tool calls if (data.type === 'message') { - console.log("Tool Calls: ", content) - for (const toolCall of content.tool_calls) { + if (content.tool_calls && content.tool_calls.length > 0) { + console.log("Tool Calls: ", content) + for (const toolCall of content.tool_calls) { - const toolMessage: ToolMessage = { - pending: true, - id: uuidv4(), - role: 'tool', - content: '', - awaitingApproval: false, - toolCall: toolCall + const toolMessage: ToolMessage = { + pending: true, + id: uuidv4(), + role: 'tool', + content: '', + awaitingApproval: false, + toolCall: toolCall + } + + this.currentAssistantMessage.toolCalls.push(toolMessage) } - - this.addMessage(toolMessage) } } if (data.type === 'token' && data.content) { @@ -255,7 +251,7 @@ export const useChat = defineStore('chat', { this.messages = [...this.messages]; } } catch (e) { - throw new Error(`Error parsing JSON: ${e}`); + throw new Error(`Error parsing JSON: ${e}\n\n${line}`); } } else if (line === 'data: [DONE]') { // Add the current assistant message to the messages list @@ -266,6 +262,7 @@ export const useChat = defineStore('chat', { id: '', role: 'assistant', content: '', + toolCalls: [], }; } } diff --git a/src/test/chat.test.ts b/src/test/chat.test.ts index 997ccf2..d7fa40a 100644 --- a/src/test/chat.test.ts +++ b/src/test/chat.test.ts @@ -214,6 +214,39 @@ describe('Chat Store _proccessOpeyStream', () => { expect(toolMessage.content).toBe('') }) + it('should associate tool call with current assistant message', async () => { + // create a mock stream + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: {"type": "message", "content": {"type": "ai", "content": "", "tool_calls": [{"name": "retrieve_glossary", "args": {"question": "hre"}, "id": "call_XsmUpPIeS81l9MYpieBZtr4w", "type": "tool_call"}], "tool_approval_request": false, "tool_call_id": null, "run_id": "d0c2bcbe-62f7-464b-8564-bf9263939fe1", "original": {"type": "ai", "data": {"content": "", "additional_kwargs": {"tool_calls": [{"index": 0, "id": "call_XsmUpPIeS81l9MYpieBZtr4w", "function": {"arguments": "{\\"question\\":\\"hre\\"}", "name": "retrieve_glossary"}, "type": "function"}]}, "response_metadata": {"finish_reason": "tool_calls", "model_name": "gpt-4o-2024-08-06", "system_fingerprint": "fp_eb9dce56a8"}, "type": "ai", "name": null, "id": "run-5bb065b9-440d-4678-bbdb-cd6de94a78d3", "example": false, "tool_calls": [{"name": "retrieve_glossary", "args": {"question": "hre"}, "id": "call_XsmUpPIeS81l9MYpieBZtr4w", "type": "tool_call"}], "invalid_tool_calls": [], "usage_metadata": null}}}}\n`)); + controller.close(); + }, + }); + + // mock the fetch function + global.fetch = vi.fn(() => + Promise.resolve(new Response(mockStream, { + headers: { 'content-type': 'text/event-stream' }, + status: 200, + })) + ); + + chatStore.currentAssistantMessage = { + id: '123', + role: 'assistant', + content: '', + } + + await chatStore._processOpeyStream(mockStream) + + expect(chatStore.messages).toHaveLength(1) + + const toolMessage: ToolMessage = chatStore.messages[0] as ToolMessage + + expect(chatStore.currentAssistantMessage.toolCalls).toBeDefined() + expect(chatStore.currentAssistantMessage.toolCalls).toContain(toolMessage.toolCall) + }) + it('should throw an error when the chunk is not valid json', async () => { const invalidJsonStream = new ReadableStream({ start(controller) { diff --git a/src/test/integration/setup.ts b/src/test/integration/setup.ts new file mode 100644 index 0000000..b37b4be --- /dev/null +++ b/src/test/integration/setup.ts @@ -0,0 +1,110 @@ +import { createServer as createViteServer } from 'vite'; +import { afterAll, beforeAll } from 'vitest'; +import { ChildProcess, spawn } from 'child_process'; +import fetch from 'node-fetch'; +import path from 'path'; + +// Ports for our test servers +const EXPRESS_PORT = 8085; // Match the port in server/app.ts +const VITE_PORT = 8086; // Different from the default dev port + +let viteServer: any; +let expressServer: ChildProcess; + +/** + * Starts the Express and Vue servers for integration testing + */ +export async function setupTestServers() { + // Start Express server as a separate process + expressServer = spawn('ts-node', ['server/app.ts'], { + stdio: 'pipe', + env: { ...process.env, PORT: EXPRESS_PORT.toString() } + }); + + // Log server output for debugging + expressServer.stdout?.on('data', (data) => { + console.log(`Express server: ${data}`); + }); + + expressServer.stderr?.on('data', (data) => { + console.error(`Express server error: ${data}`); + }); + + // Start Vite dev server in test mode + viteServer = await createViteServer({ + configFile: path.resolve(__dirname, '../../../vite.config.mts'), + server: { + port: VITE_PORT, + }, + logLevel: 'silent', // Reduce console noise during tests + }); + + await viteServer.listen(VITE_PORT); + console.log(`Vite test server running at http://localhost:${VITE_PORT}`); + + // Wait for both servers to be fully ready + await waitForServer(`http://localhost:${EXPRESS_PORT}/api/status`, 30); + await waitForServer(`http://localhost:${VITE_PORT}`, 30); + + return { + expressUrl: `http://localhost:${EXPRESS_PORT}`, + viteUrl: `http://localhost:${VITE_PORT}`, + }; +} + +/** + * Stops all test servers + */ +export async function teardownTestServers() { + // Close Vite server + if (viteServer) { + await viteServer.close(); + } + + // Close Express server + if (expressServer) { + expressServer.kill('SIGTERM'); + } +} + +/** + * Helper to wait for a server to respond + */ +async function waitForServer(url: string, maxRetries = 30): Promise { + let retries = 0; + + while (retries < maxRetries) { + try { + const response = await fetch(url); + if (response.ok) { + return true; + } + } catch (error) { + // Server not ready yet + console.log(`Waiting for ${url} (attempt ${retries + 1}/${maxRetries})...`); + } + + retries++; + await new Promise(resolve => setTimeout(resolve, 1000)); // Increase wait time to 1s + } + + throw new Error(`Server at ${url} did not respond in time`); +} + +/** + * Setup and teardown hooks for vitest + */ +export function useIntegrationTestHooks() { + let servers: { expressUrl: string; viteUrl: string }; + + beforeAll(async () => { + servers = await setupTestServers(); + return servers; + }); + + afterAll(async () => { + await teardownTestServers(); + }); + + return () => servers; +} diff --git a/src/test/integration/simple.integration.test.ts b/src/test/integration/simple.integration.test.ts new file mode 100644 index 0000000..fe546e1 --- /dev/null +++ b/src/test/integration/simple.integration.test.ts @@ -0,0 +1,95 @@ +import { describe, test, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; +import { useIntegrationTestHooks } from './setup'; +import { chromium, Browser, Page, BrowserContext } from 'playwright'; + +describe('API Explorer Integration Tests', () => { + // Setup Express and Vue servers for all tests + const getServers = useIntegrationTestHooks(); + + let browser: Browser; + let context: BrowserContext; + let page: Page; + + // Setup browser for testing + beforeAll(async () => { + browser = await chromium.launch({ + headless: true, + // Use this to debug tests visually if needed + // headless: false, + // slowMo: 1000, + }); + }); + + afterAll(async () => { + await browser.close(); + }); + + beforeEach(async () => { + context = await browser.newContext(); + page = await context.newPage(); + }); + + afterEach(async () => { + await context.close(); + }); + + test('API status endpoint responds with 200', async () => { + const servers = getServers(); + + const response = await fetch(`${servers.expressUrl}/api/status`); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toHaveProperty('status'); + }); + + // Focus on API tests first since they're less complex + test('Backend API endpoints are accessible', async () => { + const servers = getServers(); + + // Test a few key endpoints + const endpoints = [ + '/api/status', + // Add more endpoints as needed + ]; + + for (const endpoint of endpoints) { + const response = await fetch(`${servers.expressUrl}${endpoint}`); + expect(response.status).toBe(200); + } + }); + + test('Vite development server is accessible', async () => { + const servers = getServers(); + const response = await fetch(servers.viteUrl); + expect(response.status).toBe(200); + }); + + // Comment out the more complex UI tests until the basic setup is working + test.skip('Home page loads correctly', async () => { + const servers = getServers(); + await page.goto(servers.viteUrl); + + // Wait for the page to load + await page.waitForSelector('title'); + + // Check that the title contains expected text + const title = await page.title(); + expect(title).toContain('API Explorer'); + }); + + test.skip('Chat widget can be opened', async () => { + const servers = getServers(); + await page.goto(servers.viteUrl); + + // Find and click the chat widget button + const chatButton = await page.waitForSelector('.chat-widget-button'); + await chatButton.click(); + + // Check that the chat container appears + await page.waitForSelector('.chat-container'); + + const chatContainerExists = await page.isVisible('.chat-container'); + expect(chatContainerExists).toBe(true); + }); +}); diff --git a/vitest.integration.config.js b/vitest.integration.config.js new file mode 100644 index 0000000..968c8ac --- /dev/null +++ b/vitest.integration.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; +import { fileURLToPath, URL } from 'node:url'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + testTimeout: 60000, // Integration tests need more time + hookTimeout: 60000, + include: ['src/test/integration/**/*.integration.test.ts'], + setupFiles: ['src/test/integration/setup.ts'], + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, +});