- Re-architect VoiceSession into full-bleed studio cockpit with overlay slide-over companion drawer - Eliminate width squishing defect on LiveInsightPanel and add native dark/light theme support - Integrate authentic Google Gemini 3.8 Flash TTS with Fenrir persona and fallback audio pipeline - Implement comprehensive Playwright E2E visual regression suite across Desktop, iPad, and iPhone (21 tests, 45 snapshots) - Migrate core application to Svelte 5 runes (, , ) with reactive stores - Maintain .NET 8 Azure Functions serverless endpoints and clean build hygiene
247 lines
8.4 KiB
TypeScript
247 lines
8.4 KiB
TypeScript
import { describe, test, expect, afterEach } from 'vitest';
|
|
import {
|
|
analyzeTurnWithOpenRouter,
|
|
generateSessionSummaryReport,
|
|
generateScenarioWithOpenRouter,
|
|
streamOpenRouterChat
|
|
} from '../services/openRouterService';
|
|
import { DEFAULT_OPENROUTER_MODEL } from '../services/apiConfig';
|
|
|
|
describe('OpenRouter Service Integration', () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const mockKey = 'sk-or-v1-mock-test-key-999';
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
test('should throw error when no API key is provided and none in environment', async () => {
|
|
const originalEnv = process.env.OPENROUTER_API_KEY;
|
|
try {
|
|
process.env.OPENROUTER_API_KEY = '';
|
|
await expect(
|
|
analyzeTurnWithOpenRouter('turn', 'title', 'desc', ['Catastrophizing'], { apiKey: '' })
|
|
).rejects.toThrow(/OpenRouter API key is missing/);
|
|
} finally {
|
|
if (originalEnv !== undefined) process.env.OPENROUTER_API_KEY = originalEnv;
|
|
}
|
|
});
|
|
|
|
test('analyzeTurnWithOpenRouter sends expected headers and uses custom model when specified', async () => {
|
|
let capturedUrl = '';
|
|
let capturedHeaders: any = {};
|
|
let capturedBody: any = {};
|
|
|
|
globalThis.fetch = async (url: any, init: any) => {
|
|
capturedUrl = url.toString();
|
|
capturedHeaders = init?.headers;
|
|
capturedBody = JSON.parse(init?.body as string);
|
|
|
|
const mockResponse = {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: JSON.stringify({
|
|
analysis: { distortions: ['Mind Reading'], isAdaptive: false, evidence: 'User assumed negative intent.' },
|
|
rating: { score: 2, reason: 'High distortion presence' },
|
|
suggestions: { alternativeThought: 'Ask for clarification first.', behavioralStep: 'Send a follow-up text.' }
|
|
})
|
|
}
|
|
}
|
|
]
|
|
};
|
|
|
|
return new Response(JSON.stringify(mockResponse), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
};
|
|
|
|
const result = await analyzeTurnWithOpenRouter(
|
|
'User said: "They hate me."',
|
|
'Silent Friend',
|
|
'A friend has not replied.',
|
|
['Mind Reading'],
|
|
{ apiKey: mockKey, model: 'google/gemini-3.8-flash' }
|
|
);
|
|
|
|
expect(capturedUrl).toBe('https://openrouter.ai/api/v1/chat/completions');
|
|
expect(capturedHeaders['Authorization']).toBe(`Bearer ${mockKey}`);
|
|
expect(capturedHeaders['HTTP-Referer']).toBe('https://cognitive-clarity.app');
|
|
expect(capturedBody.model).toBe('google/gemini-3.8-flash');
|
|
expect(capturedBody.response_format?.type).toBe('json_object');
|
|
expect(result.analysis.isAdaptive).toBe(false);
|
|
expect(result.analysis.distortions).toEqual(['Mind Reading']);
|
|
expect(result.rating.score).toBe(2);
|
|
});
|
|
|
|
test('generateScenarioWithOpenRouter defaults to DEFAULT_OPENROUTER_MODEL when model option is omitted', async () => {
|
|
let capturedBody: any = {};
|
|
|
|
globalThis.fetch = async (url: any, init: any) => {
|
|
capturedBody = JSON.parse(init?.body as string);
|
|
|
|
const mockScenario = {
|
|
id: 'mock-1',
|
|
title: 'Workplace Feedback',
|
|
characterName: 'Boss',
|
|
description: 'Meeting about quarterly review',
|
|
category: 'Workplace',
|
|
difficulty: 'Intermediate',
|
|
systemInstruction: 'INTERNAL CBT LOGIC: React defensively if blamed.',
|
|
initialMessage: 'We need to discuss this report.',
|
|
cbtFocus: ['All-or-Nothing Thinking']
|
|
};
|
|
|
|
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(mockScenario) } }] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
};
|
|
|
|
const scenario = await generateScenarioWithOpenRouter(
|
|
'Quarterly review',
|
|
['All-or-Nothing Thinking'],
|
|
'Intermediate',
|
|
{ apiKey: mockKey }
|
|
);
|
|
|
|
expect(capturedBody.model).toBe(DEFAULT_OPENROUTER_MODEL);
|
|
expect(scenario.title).toBe('Workplace Feedback');
|
|
expect(scenario.difficulty).toBe('Intermediate');
|
|
});
|
|
|
|
test('streamOpenRouterChat streams SSE tokens and handles completion', async () => {
|
|
const sseChunks = [
|
|
'data: {"choices":[{"delta":{"content":"I "}}]}\n\n',
|
|
'data: {"choices":[{"delta":{"content":"understand."}}]}\n\n',
|
|
'data: [DONE]\n\n'
|
|
];
|
|
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
for (const chunk of sseChunks) {
|
|
controller.enqueue(encoder.encode(chunk));
|
|
}
|
|
controller.close();
|
|
}
|
|
});
|
|
|
|
globalThis.fetch = async () => {
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/event-stream' }
|
|
});
|
|
};
|
|
|
|
const collectedChunks: string[] = [];
|
|
let completedText = '';
|
|
|
|
const result = await streamOpenRouterChat(
|
|
[{ id: '1', role: 'user', text: 'Hello', timestamp: new Date() }],
|
|
'You are a peer.',
|
|
{
|
|
onChunk: (chunk) => collectedChunks.push(chunk),
|
|
onComplete: (full) => { completedText = full; }
|
|
},
|
|
{ apiKey: mockKey, model: 'google/gemini-3.8-flash' }
|
|
);
|
|
|
|
expect(result).toBe('I understand.');
|
|
expect(completedText).toBe('I understand.');
|
|
expect(collectedChunks).toEqual(['I ', 'understand.']);
|
|
});
|
|
|
|
test('generateSessionSummaryReport sends all conversation turns and parses clinical report', async () => {
|
|
let capturedBody: any = {};
|
|
|
|
globalThis.fetch = async (url: any, init: any) => {
|
|
capturedBody = JSON.parse(init?.body as string);
|
|
|
|
const mockReport = {
|
|
analysis: {
|
|
distortions: ['Catastrophizing'],
|
|
isAdaptive: false,
|
|
evidence: 'I am definitely going to get fired.'
|
|
},
|
|
rating: {
|
|
score: 3,
|
|
reason: 'Showed initial panic but engaged in dialogue.'
|
|
},
|
|
suggestions: {
|
|
alternativeThought: 'Receiving negative feedback does not mean immediate termination.',
|
|
behavioralStep: 'Schedule a clarifying 1-on-1 meeting next week.'
|
|
}
|
|
};
|
|
|
|
return new Response(JSON.stringify({
|
|
choices: [{ message: { content: JSON.stringify(mockReport) } }]
|
|
}), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
};
|
|
|
|
const messages = [
|
|
{ id: '1', role: 'model' as const, text: 'Your performance was lacking this month.', timestamp: new Date() },
|
|
{ id: '2', role: 'user' as const, text: 'I am definitely going to get fired.', timestamp: new Date() }
|
|
];
|
|
|
|
const report = await generateSessionSummaryReport(
|
|
messages,
|
|
'Performance Review',
|
|
'Manager discusses quarterly output',
|
|
{ apiKey: mockKey, model: 'google/gemini-3.8-flash' }
|
|
);
|
|
|
|
expect(capturedBody.model).toBe('google/gemini-3.8-flash');
|
|
expect(report.analysis.distortions).toEqual(['Catastrophizing']);
|
|
expect(report.rating.score).toBe(3);
|
|
expect(report.suggestions.alternativeThought).toContain('termination');
|
|
});
|
|
|
|
test('synthesizeSpeechWithOpenRouter returns audio ArrayBuffer on success', async () => {
|
|
const mockAudioBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
let capturedUrl = '';
|
|
let capturedBody: any = {};
|
|
|
|
globalThis.fetch = async (url: any, init: any) => {
|
|
capturedUrl = url.toString();
|
|
capturedBody = JSON.parse(init?.body as string);
|
|
|
|
return new Response(mockAudioBytes.buffer, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'audio/mpeg' }
|
|
});
|
|
};
|
|
|
|
const { synthesizeSpeechWithOpenRouter } = await import('../services/openRouterService');
|
|
const buffer = await synthesizeSpeechWithOpenRouter(
|
|
'Good morning. How are you feeling today?',
|
|
'nova',
|
|
mockKey
|
|
);
|
|
|
|
expect(capturedUrl).toBe('https://openrouter.ai/api/v1/audio/speech');
|
|
expect(capturedBody.input).toBe('Good morning. How are you feeling today?');
|
|
expect(capturedBody.voice).toBe('nova');
|
|
expect(buffer.byteLength).toBe(8);
|
|
});
|
|
|
|
test('synthesizeSpeechWithOpenRouter throws error on non-200 response', async () => {
|
|
globalThis.fetch = async () => {
|
|
return new Response('Unauthorized voice quota exceeded', {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'text/plain' }
|
|
});
|
|
};
|
|
|
|
const { synthesizeSpeechWithOpenRouter } = await import('../services/openRouterService');
|
|
await expect(
|
|
synthesizeSpeechWithOpenRouter('Hello', 'alloy', mockKey)
|
|
).rejects.toThrow(/OpenRouter TTS synthesis failed with status 401/);
|
|
});
|
|
});
|
|
|