Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 248 additions & 0 deletions packages/compass-e2e-tests/helpers/assistant-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import http from 'http';
import { once } from 'events';
import type { AddressInfo } from 'net';

export type MockAssistantResponse = {
status: number;
body: string;
};

function sendStreamingResponse(res: http.ServerResponse, content: string) {
// OpenAI Responses API streaming response format using Server-Sent Events
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Transfer-Encoding': 'chunked',
});

const responseId = `resp_${Date.now()}`;
const itemId = `item_${Date.now()}`;
let sequenceNumber = 0;

// Send response.created event
res.write(
`data: ${JSON.stringify({
type: 'response.created',
response: {
id: responseId,
object: 'realtime.response',
status: 'in_progress',
output: [],
usage: {
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
},
},
sequence_number: sequenceNumber++,
})}\n\n`
);

// Send output_item.added event
res.write(
`data: ${JSON.stringify({
type: 'response.output_item.added',
response_id: responseId,
output_index: 0,
item: {
id: itemId,
object: 'realtime.item',
type: 'message',
role: 'assistant',
content: [],
},
sequence_number: sequenceNumber++,
})}\n\n`
);

// Send the content in chunks
const words = content.split(' ');
let index = 0;

const sendChunk = () => {
if (index < words.length) {
const word = words[index] + (index < words.length - 1 ? ' ' : '');
// Send output_text.delta event
res.write(
`data: ${JSON.stringify({
type: 'response.output_text.delta',
response_id: responseId,
item_id: itemId,
output_index: 0,
delta: word,
sequence_number: sequenceNumber++,
})}\n\n`
);
index++;
setTimeout(sendChunk, 10);
} else {
// Send output_item.done event
res.write(
`data: ${JSON.stringify({
type: 'response.output_item.done',
response_id: responseId,
output_index: 0,
item: {
id: itemId,
object: 'realtime.item',
type: 'message',
role: 'assistant',
content: [
{
type: 'text',
text: content,
},
],
},
sequence_number: sequenceNumber++,
})}\n\n`
);

// Send response.completed event
const tokenCount = Math.ceil(content.split(' ').length * 1.3);
res.write(
`data: ${JSON.stringify({
type: 'response.completed',
response: {
id: responseId,
object: 'realtime.response',
status: 'completed',
output: [
{
id: itemId,
object: 'realtime.item',
type: 'message',
role: 'assistant',
content: [
{
type: 'text',
text: content,
},
],
},
],
usage: {
input_tokens: 10,
output_tokens: tokenCount,
total_tokens: 10 + tokenCount,
},
},
sequence_number: sequenceNumber++,
})}\n\n`
);

res.write('data: [DONE]\n\n');
res.end();
}
};

sendChunk();
}

export async function startMockAssistantServer(
{
response: _response,
}: {
response: MockAssistantResponse;
} = {
response: {
status: 200,
body: 'This is a test response from the AI assistant.',
},
}
): Promise<{
clearRequests: () => void;
getResponse: () => MockAssistantResponse;
setResponse: (response: MockAssistantResponse) => void;
getRequests: () => {
content: any;
req: any;
}[];
endpoint: string;
server: http.Server;
stop: () => Promise<void>;
}> {
let requests: {
content: any;
req: any;
}[] = [];
let response = _response;
const server = http
.createServer((req, res) => {
// Only handle POST requests for chat completions
if (req.method !== 'POST') {
res.writeHead(404);
return res.end('Not Found');
}

let body = '';
req
.setEncoding('utf8')
.on('data', (chunk) => {
body += chunk;
})
.on('end', () => {
let jsonObject;
try {
jsonObject = JSON.parse(body);
} catch {
res.writeHead(400);
res.setHeader('Content-Type', 'application/json');
return res.end(JSON.stringify({ error: 'Invalid JSON' }));
}

requests.push({
req,
content: jsonObject,
});

if (response.status !== 200) {
res.writeHead(response.status);
res.setHeader('Content-Type', 'application/json');
return res.end(JSON.stringify({ error: response.body }));
}

// Send streaming response
return sendStreamingResponse(res, response.body);
});
})
.listen(0);
await once(server, 'listening');

// address() returns either a string or AddressInfo.
const address = server.address() as AddressInfo;

const endpoint = `http://localhost:${address.port}`;

async function stop() {
server.close();
await once(server, 'close');
}

function clearRequests() {
requests = [];
}

function getRequests() {
return requests;
}

function getResponse() {
return response;
}

function setResponse(newResponse: MockAssistantResponse) {
response = newResponse;
}

return {
clearRequests,
getRequests,
endpoint,
server,
getResponse,
setResponse,
stop,
};
}
21 changes: 19 additions & 2 deletions packages/compass-e2e-tests/helpers/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export const SettingsModalTabSelector = (name: string) =>
`${SettingsModal} [data-testid="sidebar-${name}-item"]`;
export const GeneralSettingsButton = SettingsModalTabSelector('general');
export const GeneralSettingsContent = `${SettingsModal} [data-testid="general-settings"]`;
export const ArtificialIntelligenceSettingsButton =
SettingsModalTabSelector('ai');
export const ArtificialIntelligenceSettingsContent = `${SettingsModal} [data-testid="gen-ai-settings"]`;

export const SettingsInputElement = (settingName: string): string => {
return `${SettingsModal} [data-testid="${settingName}"]`;
Expand Down Expand Up @@ -894,6 +897,9 @@ export const AggregationSavedPipelineCardDeleteButton = (
export const AggregationExplainButton =
'[data-testid="pipeline-toolbar-explain-aggregation-button"]';
export const AggregationExplainModal = '[data-testid="explain-plan-modal"]';
export const ExplainPlanInterpretButton =
'[data-testid="interpret-for-me-button"]';
export const ExplainPlanCloseButton = '[data-testid="explain-close-button"]';
export const AggregationExplainModalCloseButton = `${AggregationExplainModal} [aria-label*="Close"]`;

// Create view from pipeline modal
Expand Down Expand Up @@ -1510,8 +1516,19 @@ export const SideDrawerCloseButton = `[data-testid="${
}"]`;

// Assistant
export const AssistantDrawerButton = 'button[aria-label="MongoDB Assistant"]';
export const AssistantDrawerCloseButton = `[data-testid="lg-drawer-close_button"]`;
export const AssistantChatMessages = '[data-testid="assistant-chat-messages"]';
export const AssistantChatMessage = '[data-testid^="assistant-message-"]';
export const AssistantChatInput = '[data-testid="assistant-chat-input"]';
export const AssistantChatInputTextArea = `${AssistantChatInput} textarea`;
export const AssistantChatSubmitButton = `${AssistantChatInput} button[aria-label="Send message"]`;
export const AssistantClearChatButton = '[data-testid="assistant-clear-chat"]';
export const ConfirmClearChatModal =
export const AssistantConfirmClearChatModal =
'[data-testid="assistant-confirm-clear-chat-modal"]';
export const ConfirmClearChatModalConfirmButton = `${ConfirmClearChatModal} [data-testid="lg-confirmation_modal-footer-confirm_button"]`;
export const AssistantConfirmClearChatModalConfirmButton = `${AssistantConfirmClearChatModal} [data-testid="lg-confirmation_modal-footer-confirm_button"]`;

// AI Opt-in Modal
export const AIOptInModal = '[data-testid="ai-optin-modal"]';
export const AIOptInModalAcceptButton = 'button=Use AI Features';
export const AIOptInModalDeclineLink = 'span=Not now';
Loading