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))
+ }
+ },
+});