diff --git a/client/e2e/full-roundtrip-profile.spec.ts b/client/e2e/full-roundtrip-profile.spec.ts new file mode 100644 index 0000000..433ff2f --- /dev/null +++ b/client/e2e/full-roundtrip-profile.spec.ts @@ -0,0 +1,979 @@ +/** + * Full Round-Trip Performance Profiling Suite + * + * Measures complete flow: Frontend → gRPC → Backend → Database → Response + * + * Covers: + * - Meeting CRUD operations (create, read, update, delete) + * - Recording flow simulation + * - Diarization pipeline + * - Transcription segments + * - Summarization generation + * - Entity extraction + * - Annotation CRUD + * - Task management + * - Analytics aggregation + * - Export operations + */ + +import { expect, test, type Page } from '@playwright/test'; +import { callAPI, navigateTo, TEST_DATA, waitForAPI, waitForLoadingComplete } from './fixtures'; + +const shouldRun = process.env.NOTEFLOW_E2E === '1'; + +// Performance thresholds for full round-trip (milliseconds) +const RT_THRESHOLDS = { + // Fast operations (simple DB lookup) + SIMPLE_READ: 500, + SIMPLE_WRITE: 1000, + + // Medium operations (joins, aggregations) + MEDIUM_READ: 2000, + MEDIUM_WRITE: 3000, + + // Heavy operations (AI processing, large data) + HEAVY_PROCESSING: 30000, + AI_OPERATION: 60000, + + // Full page with multiple API calls + FULL_PAGE_LOAD: 5000, +}; + +interface RoundTripMetric { + operation: string; + flow: string; + duration: number; + threshold: number; + passed: boolean; + dataSize?: number; + details?: string; +} + +const allMetrics: RoundTripMetric[] = []; + +function recordRoundTrip( + operation: string, + flow: string, + duration: number, + threshold: number, + dataSize?: number, + details?: string +) { + const passed = duration <= threshold; + allMetrics.push({ operation, flow, duration, threshold, passed, dataSize, details }); + + const sizeInfo = dataSize ? ` [${dataSize} items]` : ''; + const status = passed ? '✓' : '✗'; + console.log( + `[RT] ${status} ${operation} (${flow}): ${duration.toFixed(0)}ms${sizeInfo} (threshold: ${threshold}ms)` + ); + return passed; +} + +async function measureRoundTrip(fn: () => Promise): Promise<{ result: T; duration: number }> { + const start = performance.now(); + const result = await fn(); + const duration = performance.now() - start; + return { result, duration }; +} + +// ============================================================================= +// 1. MEETING CRUD - Full Round Trip +// ============================================================================= + +test.describe('Meeting CRUD Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + let testMeetingId: string | null = null; + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + }); + + test.afterEach(async ({ page }) => { + // Cleanup test meeting if created + if (testMeetingId) { + try { + await callAPI(page, 'deleteMeeting', { meeting_id: testMeetingId }); + } catch { + // Ignore cleanup errors + } + testMeetingId = null; + } + }); + + test('CREATE meeting - full round trip', async ({ page }) => { + const testTitle = `Perf Test Meeting ${Date.now()}`; + + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ id: string; title: string }>(page, 'createMeeting', { + title: testTitle, + metadata: { test: 'performance' }, + }); + }); + + testMeetingId = result.id; + const passed = recordRoundTrip('CREATE Meeting', 'FE→gRPC→DB→Response', duration, RT_THRESHOLDS.SIMPLE_WRITE); + expect(result.title).toBe(testTitle); + expect(passed).toBe(true); + }); + + test('READ meeting list - full round trip', async ({ page }) => { + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ meetings: unknown[]; total_count: number }>(page, 'listMeetings', { + limit: 50, + offset: 0, + }); + }); + + recordRoundTrip( + 'READ Meeting List', + 'FE→gRPC→DB(query+count)→Response', + duration, + RT_THRESHOLDS.MEDIUM_READ, + result.meetings.length + ); + }); + + test('READ meeting list with filters - full round trip', async ({ page }) => { + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ meetings: unknown[] }>(page, 'listMeetings', { + limit: 50, + states: ['completed'], + sort_order: 'newest', + }); + }); + + recordRoundTrip( + 'READ Meeting List (filtered)', + 'FE→gRPC→DB(filtered query)→Response', + duration, + RT_THRESHOLDS.MEDIUM_READ, + result.meetings.length + ); + }); + + test('READ single meeting with segments - full round trip', async ({ page }) => { + const meetings = await callAPI<{ meetings: { id: string }[] }>(page, 'listMeetings', { limit: 1 }); + if (meetings.meetings.length === 0) { + test.skip(true, 'No meetings available'); + return; + } + + const meetingId = meetings.meetings[0].id; + + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ segments?: unknown[] }>(page, 'getMeeting', { + meeting_id: meetingId, + include_segments: true, + include_summary: true, + }); + }); + + recordRoundTrip( + 'READ Meeting Detail', + 'FE→gRPC→DB(meeting+segments+summary)→Response', + duration, + RT_THRESHOLDS.MEDIUM_READ, + result.segments?.length || 0 + ); + }); + + test('DELETE meeting - full round trip', async ({ page }) => { + // Create a meeting to delete + const createResult = await callAPI<{ id: string }>(page, 'createMeeting', { + title: `Delete Test ${Date.now()}`, + }); + + const { duration } = await measureRoundTrip(async () => { + return callAPI(page, 'deleteMeeting', { meeting_id: createResult.id }); + }); + + recordRoundTrip('DELETE Meeting', 'FE→gRPC→DB(cascade delete)→Response', duration, RT_THRESHOLDS.MEDIUM_WRITE); + }); +}); + +// ============================================================================= +// 2. SPEAKER/DIARIZATION ROUND TRIP +// ============================================================================= + +test.describe('Diarization Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + }); + + test('READ speaker stats - aggregation round trip', async ({ page }) => { + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ speakers: unknown[] }>(page, 'listSpeakerStats', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + }); + }); + + recordRoundTrip( + 'READ Speaker Stats', + 'FE→gRPC→DB(aggregate speakers)→Response', + duration, + RT_THRESHOLDS.MEDIUM_READ, + result.speakers.length + ); + }); + + test('READ speaker stats with project filter - filtered aggregation', async ({ page }) => { + const projects = await callAPI<{ projects: { id: string }[] }>(page, 'listProjects', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + }); + + if (projects.projects.length === 0) { + test.skip(true, 'No projects available'); + return; + } + + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ speakers: unknown[] }>(page, 'listSpeakerStats', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + project_id: projects.projects[0].id, + }); + }); + + recordRoundTrip( + 'READ Speaker Stats (project filtered)', + 'FE→gRPC→DB(filtered aggregate)→Response', + duration, + RT_THRESHOLDS.MEDIUM_READ, + result.speakers.length + ); + }); + + test('GET diarization job status - status check round trip', async ({ page }) => { + const meetings = await callAPI<{ meetings: { id: string }[] }>(page, 'listMeetings', { limit: 1 }); + if (meetings.meetings.length === 0) { + test.skip(true, 'No meetings available'); + return; + } + + const { duration } = await measureRoundTrip(async () => { + return callAPI(page, 'getDiarizationJobStatus', { + meeting_id: meetings.meetings[0].id, + }); + }); + + recordRoundTrip('READ Diarization Status', 'FE→gRPC→DB(job lookup)→Response', duration, RT_THRESHOLDS.SIMPLE_READ); + }); + + test('RENAME speaker - update round trip', async ({ page }) => { + const meetings = await callAPI<{ meetings: { id: string }[] }>(page, 'listMeetings', { limit: 1 }); + if (meetings.meetings.length === 0) { + test.skip(true, 'No meetings available'); + return; + } + + const meeting = await callAPI<{ segments?: { speaker_id: string }[] }>(page, 'getMeeting', { + meeting_id: meetings.meetings[0].id, + include_segments: true, + }); + + const speakerId = meeting.segments?.[0]?.speaker_id; + if (!speakerId) { + test.skip(true, 'No speakers in meeting'); + return; + } + + const { duration } = await measureRoundTrip(async () => { + return callAPI(page, 'renameSpeaker', { + meeting_id: meetings.meetings[0].id, + speaker_id: speakerId, + new_name: `Speaker ${Date.now()}`, + }); + }); + + recordRoundTrip('UPDATE Speaker Name', 'FE→gRPC→DB(update)→Response', duration, RT_THRESHOLDS.SIMPLE_WRITE); + }); +}); + +// ============================================================================= +// 3. ANALYTICS AGGREGATION ROUND TRIP +// ============================================================================= + +test.describe('Analytics Aggregation Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + }); + + test('GET analytics overview - complex aggregation', async ({ page }) => { + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ daily?: unknown[]; total_meetings: number }>(page, 'getAnalyticsOverview', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + }); + }); + + recordRoundTrip( + 'READ Analytics Overview', + 'FE→gRPC→DB(multi-table aggregate)→Response', + duration, + RT_THRESHOLDS.MEDIUM_READ, + result.daily?.length || 0, + `${result.total_meetings} total meetings` + ); + }); + + test('GET entity analytics - NER aggregation', async ({ page }) => { + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ total_entities: number; total_mentions: number }>(page, 'getEntityAnalytics', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + }); + }); + + recordRoundTrip( + 'READ Entity Analytics', + 'FE→gRPC→DB(entity aggregate)→Response', + duration, + RT_THRESHOLDS.MEDIUM_READ, + result.total_entities, + `${result.total_mentions} mentions` + ); + }); +}); + +// ============================================================================= +// 4. TASK MANAGEMENT ROUND TRIP +// ============================================================================= + +test.describe('Task Management Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + }); + + test('READ task list - join query round trip', async ({ page }) => { + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ tasks: unknown[]; total_count: number }>(page, 'listTasks', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + limit: 50, + }); + }); + + recordRoundTrip( + 'READ Task List', + 'FE→gRPC→DB(tasks+meetings join)→Response', + duration, + RT_THRESHOLDS.MEDIUM_READ, + result.tasks.length + ); + }); + + test('READ task list with status filter - filtered join', async ({ page }) => { + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ tasks: unknown[] }>(page, 'listTasks', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + statuses: ['open'], + limit: 50, + }); + }); + + recordRoundTrip( + 'READ Task List (filtered)', + 'FE→gRPC→DB(filtered join)→Response', + duration, + RT_THRESHOLDS.MEDIUM_READ, + result.tasks.length + ); + }); + + test('UPDATE task status - status update round trip', async ({ page }) => { + const tasks = await callAPI<{ tasks: { task: { id: string; status: string } }[] }>(page, 'listTasks', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + limit: 1, + }); + + if (tasks.tasks.length === 0) { + test.skip(true, 'No tasks available'); + return; + } + + const task = tasks.tasks[0].task; + const newStatus = task.status === 'open' ? 'in_progress' : 'open'; + + const { duration } = await measureRoundTrip(async () => { + return callAPI(page, 'updateTask', { + task_id: task.id, + status: newStatus, + }); + }); + + // Revert status + await callAPI(page, 'updateTask', { task_id: task.id, status: task.status }); + + recordRoundTrip('UPDATE Task Status', 'FE→gRPC→DB(update)→Response', duration, RT_THRESHOLDS.SIMPLE_WRITE); + }); +}); + +// ============================================================================= +// 5. ANNOTATION CRUD ROUND TRIP +// ============================================================================= + +test.describe('Annotation CRUD Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + let testAnnotationId: string | null = null; + let testMeetingId: string | null = null; + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const meetings = await callAPI<{ meetings: { id: string }[] }>(page, 'listMeetings', { limit: 1 }); + if (meetings.meetings.length > 0) { + testMeetingId = meetings.meetings[0].id; + } + }); + + test.afterEach(async ({ page }) => { + if (testAnnotationId && testMeetingId) { + try { + await callAPI(page, 'deleteAnnotation', { annotation_id: testAnnotationId }); + } catch { + // Ignore + } + testAnnotationId = null; + } + }); + + test('CREATE annotation - insert round trip', async ({ page }) => { + if (!testMeetingId) { + test.skip(true, 'No meetings available'); + return; + } + + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ id: string }>(page, 'addAnnotation', { + meeting_id: testMeetingId, + annotation_type: 'note', + text: `Performance test annotation ${Date.now()}`, + start_time: 0, + end_time: 1000, + }); + }); + + testAnnotationId = result.id; + recordRoundTrip('CREATE Annotation', 'FE→gRPC→DB(insert)→Response', duration, RT_THRESHOLDS.SIMPLE_WRITE); + }); + + test('READ annotations - list query round trip', async ({ page }) => { + if (!testMeetingId) { + test.skip(true, 'No meetings available'); + return; + } + + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ annotations: unknown[] }>(page, 'listAnnotations', { + meeting_id: testMeetingId, + }); + }); + + recordRoundTrip( + 'READ Annotations', + 'FE→gRPC→DB(query)→Response', + duration, + RT_THRESHOLDS.SIMPLE_READ, + result.annotations.length + ); + }); + + test('UPDATE annotation - update round trip', async ({ page }) => { + if (!testMeetingId) { + test.skip(true, 'No meetings available'); + return; + } + + // Create an annotation to update + const created = await callAPI<{ id: string }>(page, 'addAnnotation', { + meeting_id: testMeetingId, + annotation_type: 'note', + text: 'Original text', + start_time: 0, + end_time: 1000, + }); + testAnnotationId = created.id; + + const { duration } = await measureRoundTrip(async () => { + return callAPI(page, 'updateAnnotation', { + annotation_id: testAnnotationId, + text: 'Updated text', + }); + }); + + recordRoundTrip('UPDATE Annotation', 'FE→gRPC→DB(update)→Response', duration, RT_THRESHOLDS.SIMPLE_WRITE); + }); + + test('DELETE annotation - delete round trip', async ({ page }) => { + if (!testMeetingId) { + test.skip(true, 'No meetings available'); + return; + } + + // Create an annotation to delete + const created = await callAPI<{ id: string }>(page, 'addAnnotation', { + meeting_id: testMeetingId, + annotation_type: 'note', + text: 'To be deleted', + start_time: 0, + end_time: 1000, + }); + + const { duration } = await measureRoundTrip(async () => { + return callAPI(page, 'deleteAnnotation', { annotation_id: created.id }); + }); + + testAnnotationId = null; // Already deleted + recordRoundTrip('DELETE Annotation', 'FE→gRPC→DB(delete)→Response', duration, RT_THRESHOLDS.SIMPLE_WRITE); + }); +}); + +// ============================================================================= +// 6. PROJECT OPERATIONS ROUND TRIP +// ============================================================================= + +test.describe('Project Operations Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + }); + + test('READ project list - list query round trip', async ({ page }) => { + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ projects: unknown[] }>(page, 'listProjects', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + }); + }); + + recordRoundTrip( + 'READ Project List', + 'FE→gRPC→DB(query)→Response', + duration, + RT_THRESHOLDS.SIMPLE_READ, + result.projects.length + ); + }); + + test('READ meetings by project - filtered query round trip', async ({ page }) => { + const projects = await callAPI<{ projects: { id: string }[] }>(page, 'listProjects', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + }); + + if (projects.projects.length === 0) { + test.skip(true, 'No projects available'); + return; + } + + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ meetings: unknown[] }>(page, 'listMeetings', { + project_id: projects.projects[0].id, + limit: 50, + }); + }); + + recordRoundTrip( + 'READ Meetings by Project', + 'FE→gRPC→DB(filtered query)→Response', + duration, + RT_THRESHOLDS.MEDIUM_READ, + result.meetings.length + ); + }); +}); + +// ============================================================================= +// 7. WEBHOOKS ROUND TRIP +// ============================================================================= + +test.describe('Webhooks Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + }); + + test('READ webhook list - list query round trip', async ({ page }) => { + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ webhooks: unknown[] }>(page, 'listWebhooks', { + include_test: false, + }); + }); + + recordRoundTrip( + 'READ Webhook List', + 'FE→gRPC→DB(query)→Response', + duration, + RT_THRESHOLDS.SIMPLE_READ, + result.webhooks.length + ); + }); +}); + +// ============================================================================= +// 8. SETTINGS/PREFERENCES ROUND TRIP +// ============================================================================= + +test.describe('Settings Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + }); + + test('READ preferences - config lookup round trip', async ({ page }) => { + const { duration } = await measureRoundTrip(async () => { + return callAPI(page, 'getPreferences', {}); + }); + + recordRoundTrip('READ Preferences', 'FE→gRPC→Config/DB→Response', duration, RT_THRESHOLDS.SIMPLE_READ); + }); + + test('READ server info - system status round trip', async ({ page }) => { + const { duration } = await measureRoundTrip(async () => { + return callAPI(page, 'getServerInfo', {}); + }); + + recordRoundTrip('READ Server Info', 'FE→gRPC→System→Response', duration, RT_THRESHOLDS.SIMPLE_READ); + }); + + test('READ cloud consent status - auth/config round trip', async ({ page }) => { + const { duration } = await measureRoundTrip(async () => { + return callAPI(page, 'getCloudConsentStatus', {}); + }); + + recordRoundTrip('READ Cloud Consent', 'FE→gRPC→Config→Response', duration, RT_THRESHOLDS.SIMPLE_READ); + }); + + test('READ audio devices - system enumeration round trip', async ({ page }) => { + const { result, duration } = await measureRoundTrip(async () => { + return callAPI<{ input: unknown[]; output: unknown[] }>(page, 'listAudioDevices', {}); + }); + + recordRoundTrip( + 'READ Audio Devices', + 'FE→Rust/Tauri→System→Response', + duration, + RT_THRESHOLDS.SIMPLE_READ, + (result.input?.length || 0) + (result.output?.length || 0) + ); + }); + + test('READ trigger status - config lookup round trip', async ({ page }) => { + const { duration } = await measureRoundTrip(async () => { + return callAPI(page, 'getTriggerStatus', {}); + }); + + recordRoundTrip('READ Trigger Status', 'FE→Rust/Tauri→Config→Response', duration, RT_THRESHOLDS.SIMPLE_READ); + }); +}); + +// ============================================================================= +// 9. AI ASSISTANT ROUND TRIP +// ============================================================================= + +test.describe('AI Assistant Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test('ASK assistant - AI inference round trip', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const meetings = await callAPI<{ meetings: { id: string }[] }>(page, 'listMeetings', { limit: 1 }); + if (meetings.meetings.length === 0) { + test.skip(true, 'No meetings available'); + return; + } + + const { result, duration } = await measureRoundTrip(async () => { + try { + return await callAPI<{ answer: string }>(page, 'askAssistant', { + question: 'What is the main topic?', + meeting_id: meetings.meetings[0].id, + }); + } catch (e) { + return { answer: '', error: String(e) }; + } + }); + + recordRoundTrip( + 'AI Assistant Query', + 'FE→gRPC→LLM(inference)→DB(context)→Response', + duration, + RT_THRESHOLDS.AI_OPERATION, + undefined, + `Answer length: ${result.answer?.length || 0}` + ); + }); +}); + +// ============================================================================= +// 10. POST-PROCESSING PIPELINE ROUND TRIP +// ============================================================================= + +test.describe('Post-Processing Pipeline Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + }); + + test('TRIGGER diarization refinement - async job round trip', async ({ page }) => { + const meetings = await callAPI<{ meetings: { id: string; state: string }[] }>(page, 'listMeetings', { + limit: 10, + states: ['completed'], + }); + + const completedMeeting = meetings.meetings.find((m) => m.state === 'completed'); + if (!completedMeeting) { + test.skip(true, 'No completed meetings'); + return; + } + + const { duration } = await measureRoundTrip(async () => { + try { + return await callAPI(page, 'refineSpeakers', { + meeting_id: completedMeeting.id, + }); + } catch { + return { skipped: true }; + } + }); + + recordRoundTrip( + 'TRIGGER Diarization', + 'FE→gRPC→JobQueue→Audio Processing→DB→Response', + duration, + RT_THRESHOLDS.HEAVY_PROCESSING + ); + }); + + test('TRIGGER summary generation - AI generation round trip', async ({ page }) => { + const meetings = await callAPI<{ meetings: { id: string; state: string }[] }>(page, 'listMeetings', { + limit: 10, + states: ['completed'], + }); + + const completedMeeting = meetings.meetings.find((m) => m.state === 'completed'); + if (!completedMeeting) { + test.skip(true, 'No completed meetings'); + return; + } + + const { duration } = await measureRoundTrip(async () => { + try { + return await callAPI(page, 'generateSummary', { + meeting_id: completedMeeting.id, + }); + } catch { + return { skipped: true }; + } + }); + + recordRoundTrip( + 'TRIGGER Summary Generation', + 'FE→gRPC→LLM(inference)→DB(store)→Response', + duration, + RT_THRESHOLDS.AI_OPERATION + ); + }); + + test('TRIGGER entity extraction - NER round trip', async ({ page }) => { + const meetings = await callAPI<{ meetings: { id: string; state: string }[] }>(page, 'listMeetings', { + limit: 10, + states: ['completed'], + }); + + const completedMeeting = meetings.meetings.find((m) => m.state === 'completed'); + if (!completedMeeting) { + test.skip(true, 'No completed meetings'); + return; + } + + const { duration } = await measureRoundTrip(async () => { + try { + return await callAPI(page, 'extractEntities', { + meeting_id: completedMeeting.id, + }); + } catch { + return { skipped: true }; + } + }); + + recordRoundTrip( + 'TRIGGER Entity Extraction', + 'FE→gRPC→NER Model→DB(store)→Response', + duration, + RT_THRESHOLDS.HEAVY_PROCESSING + ); + }); +}); + +// ============================================================================= +// 11. CONCURRENT OPERATIONS +// ============================================================================= + +test.describe('Concurrent Operations Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test('CONCURRENT reads - parallel query round trip', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const { duration } = await measureRoundTrip(async () => { + return Promise.all([ + callAPI(page, 'listMeetings', { limit: 20 }), + callAPI(page, 'listSpeakerStats', { workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID }), + callAPI(page, 'listTasks', { workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, limit: 20 }), + callAPI(page, 'getAnalyticsOverview', { workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID }), + callAPI(page, 'listProjects', { workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID }), + ]); + }); + + recordRoundTrip( + 'CONCURRENT 5 Reads', + 'FE→gRPC(parallel)→DB(5 queries)→Response', + duration, + RT_THRESHOLDS.MEDIUM_READ * 1.5, // Allow 50% overhead for parallelism + 5 + ); + }); +}); + +// ============================================================================= +// 12. FULL PAGE LOAD ROUND TRIP +// ============================================================================= + +test.describe('Full Page Load Round Trip', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test('HOME page - full load with API calls', async ({ page }) => { + const { duration } = await measureRoundTrip(async () => { + await page.goto('/'); + await waitForLoadingComplete(page); + await waitForAPI(page); + }); + + recordRoundTrip('FULL PAGE: Home', 'Navigate→Render→API calls→Complete', duration, RT_THRESHOLDS.FULL_PAGE_LOAD); + }); + + test('MEETINGS page - full load with list query', async ({ page }) => { + const { duration } = await measureRoundTrip(async () => { + await page.goto('/meetings'); + await waitForLoadingComplete(page); + }); + + recordRoundTrip('FULL PAGE: Meetings', 'Navigate→Render→listMeetings→Complete', duration, RT_THRESHOLDS.FULL_PAGE_LOAD); + }); + + test('ANALYTICS page - full load with aggregations', async ({ page }) => { + const { duration } = await measureRoundTrip(async () => { + await page.goto('/analytics'); + await waitForLoadingComplete(page); + }); + + recordRoundTrip( + 'FULL PAGE: Analytics', + 'Navigate→Render→Aggregations→Charts→Complete', + duration, + RT_THRESHOLDS.FULL_PAGE_LOAD + ); + }); + + test('PEOPLE page - full load with speaker stats', async ({ page }) => { + const { duration } = await measureRoundTrip(async () => { + await page.goto('/people'); + await waitForLoadingComplete(page); + }); + + recordRoundTrip( + 'FULL PAGE: People', + 'Navigate→Render→listSpeakerStats→Complete', + duration, + RT_THRESHOLDS.FULL_PAGE_LOAD + ); + }); + + test('TASKS page - full load with task list', async ({ page }) => { + const { duration } = await measureRoundTrip(async () => { + await page.goto('/tasks'); + await waitForLoadingComplete(page); + }); + + recordRoundTrip('FULL PAGE: Tasks', 'Navigate→Render→listTasks→Complete', duration, RT_THRESHOLDS.FULL_PAGE_LOAD); + }); +}); + +// ============================================================================= +// FINAL SUMMARY REPORT +// ============================================================================= + +test.afterAll(() => { + console.log('\n' + '='.repeat(80)); + console.log('FULL ROUND-TRIP PERFORMANCE PROFILING REPORT'); + console.log('='.repeat(80)); + + const passed = allMetrics.filter((m) => m.passed); + const failed = allMetrics.filter((m) => !m.passed); + + console.log(`\nTotal Operations Profiled: ${allMetrics.length}`); + console.log(`Passed: ${passed.length} (${((passed.length / allMetrics.length) * 100).toFixed(1)}%)`); + console.log(`Failed: ${failed.length} (${((failed.length / allMetrics.length) * 100).toFixed(1)}%)`); + + // Group by flow type + const byFlow = new Map(); + for (const m of allMetrics) { + const key = m.flow.split('→')[0]; + if (!byFlow.has(key)) { + byFlow.set(key, []); + } + byFlow.get(key)!.push(m); + } + + console.log('\n--- BY OPERATION TYPE ---'); + for (const [flow, metrics] of byFlow) { + const avgDuration = metrics.reduce((sum, m) => sum + m.duration, 0) / metrics.length; + const passRate = (metrics.filter((m) => m.passed).length / metrics.length) * 100; + console.log(`${flow}: avg ${avgDuration.toFixed(0)}ms, ${passRate.toFixed(0)}% pass rate`); + } + + if (failed.length > 0) { + console.log('\n--- FAILED OPERATIONS (VULNERABILITIES) ---'); + for (const m of failed) { + console.log( + ` ✗ ${m.operation}: ${m.duration.toFixed(0)}ms (threshold: ${m.threshold}ms) - ${m.flow}` + ); + } + } + + // Find slowest operations + const sorted = [...allMetrics].sort((a, b) => b.duration - a.duration); + console.log('\n--- TOP 10 SLOWEST OPERATIONS ---'); + for (const m of sorted.slice(0, 10)) { + const pct = ((m.duration / m.threshold) * 100).toFixed(0); + console.log(` ${m.operation}: ${m.duration.toFixed(0)}ms (${pct}% of threshold)`); + } + + console.log('\n--- ALL METRICS ---'); + for (const m of allMetrics) { + const status = m.passed ? '✓' : '✗'; + const sizeInfo = m.dataSize !== undefined ? ` [${m.dataSize}]` : ''; + console.log(` ${status} ${m.operation}: ${m.duration.toFixed(0)}ms${sizeInfo}`); + } + + console.log('\n' + '='.repeat(80)); +}); diff --git a/client/e2e/performance-profile.spec.ts b/client/e2e/performance-profile.spec.ts new file mode 100644 index 0000000..d59fb4d --- /dev/null +++ b/client/e2e/performance-profile.spec.ts @@ -0,0 +1,707 @@ +/** + * Comprehensive Performance Profiling Suite + * + * Profiles all major user flows: + * - Recording flow + * - Diarization flow + * - Transcription flow + * - Summarization flow + * - Entity extraction flow + * - Playback flow + * - Analytics computation + * - UI access paths and navigation + */ + +import { expect, test, type Page } from '@playwright/test'; +import { callAPI, navigateTo, TEST_DATA, waitForAPI, waitForLoadingComplete } from './fixtures'; + +const shouldRun = process.env.NOTEFLOW_E2E === '1'; + +// Performance thresholds (milliseconds) +const THRESHOLDS = { + PAGE_LOAD: 3000, + API_CALL: 2000, + NAVIGATION: 1500, + LIST_RENDER: 1000, + HEAVY_COMPUTATION: 10000, + REAL_TIME_UPDATE: 500, +}; + +interface PerformanceMetric { + name: string; + duration: number; + threshold: number; + passed: boolean; + details?: string; +} + +const metrics: PerformanceMetric[] = []; + +function recordMetric(name: string, duration: number, threshold: number, details?: string) { + const passed = duration <= threshold; + metrics.push({ name, duration, threshold, passed, details }); + console.log( + `[PERF] ${passed ? '✓' : '✗'} ${name}: ${duration.toFixed(0)}ms (threshold: ${threshold}ms)${details ? ` - ${details}` : ''}` + ); + return passed; +} + +async function measureAsync(fn: () => Promise): Promise<{ result: T; duration: number }> { + const start = performance.now(); + const result = await fn(); + const duration = performance.now() - start; + return { result, duration }; +} + +// ============================================================================= +// 1. PAGE LOAD PERFORMANCE +// ============================================================================= + +test.describe('Page Load Performance', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + const pages = [ + { path: '/', name: 'Home' }, + { path: '/meetings', name: 'Meetings List' }, + { path: '/tasks', name: 'Tasks' }, + { path: '/people', name: 'People' }, + { path: '/analytics', name: 'Analytics' }, + { path: '/settings', name: 'Settings' }, + ]; + + for (const { path, name } of pages) { + test(`${name} page load time`, async ({ page }) => { + const { duration } = await measureAsync(async () => { + await page.goto(path); + await waitForLoadingComplete(page); + }); + + const passed = recordMetric(`Page Load: ${name}`, duration, THRESHOLDS.PAGE_LOAD); + expect(passed).toBe(true); + }); + } +}); + +// ============================================================================= +// 2. NAVIGATION PERFORMANCE +// ============================================================================= + +test.describe('Navigation Performance', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test('sidebar navigation transitions', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const navPaths = [ + { selector: 'a[href="/meetings"]', name: 'Meetings' }, + { selector: 'a[href="/tasks"]', name: 'Tasks' }, + { selector: 'a[href="/people"]', name: 'People' }, + { selector: 'a[href="/analytics"]', name: 'Analytics' }, + { selector: 'a[href="/settings"]', name: 'Settings' }, + { selector: 'a[href="/"]', name: 'Home' }, + ]; + + for (const { selector, name } of navPaths) { + const link = page.locator(selector).first(); + if (await link.isVisible()) { + const { duration } = await measureAsync(async () => { + await link.click(); + await waitForLoadingComplete(page); + }); + recordMetric(`Navigation: ${name}`, duration, THRESHOLDS.NAVIGATION); + } + } + }); +}); + +// ============================================================================= +// 3. API PERFORMANCE +// ============================================================================= + +test.describe('API Performance', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + }); + + test('listMeetings performance', async ({ page }) => { + const { duration } = await measureAsync(async () => { + await callAPI(page, 'listMeetings', { limit: 50 }); + }); + const passed = recordMetric('API: listMeetings (50)', duration, THRESHOLDS.API_CALL); + expect(passed).toBe(true); + }); + + test('listMeetings with pagination performance', async ({ page }) => { + const { duration } = await measureAsync(async () => { + await callAPI(page, 'listMeetings', { limit: 100, offset: 0 }); + }); + recordMetric('API: listMeetings (100)', duration, THRESHOLDS.API_CALL); + }); + + test('listSpeakerStats performance', async ({ page }) => { + const { duration } = await measureAsync(async () => { + await callAPI(page, 'listSpeakerStats', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + }); + }); + const passed = recordMetric('API: listSpeakerStats', duration, THRESHOLDS.API_CALL); + expect(passed).toBe(true); + }); + + test('getAnalyticsOverview performance', async ({ page }) => { + const { duration } = await measureAsync(async () => { + await callAPI(page, 'getAnalyticsOverview', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + }); + }); + recordMetric('API: getAnalyticsOverview', duration, THRESHOLDS.API_CALL); + }); + + test('getEntityAnalytics performance', async ({ page }) => { + const { duration } = await measureAsync(async () => { + await callAPI(page, 'getEntityAnalytics', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + }); + }); + recordMetric('API: getEntityAnalytics', duration, THRESHOLDS.API_CALL); + }); + + test('listTasks performance', async ({ page }) => { + const { duration } = await measureAsync(async () => { + await callAPI(page, 'listTasks', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + limit: 50, + }); + }); + recordMetric('API: listTasks', duration, THRESHOLDS.API_CALL); + }); + + test('listProjects performance', async ({ page }) => { + const { duration } = await measureAsync(async () => { + await callAPI(page, 'listProjects', { + workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, + }); + }); + recordMetric('API: listProjects', duration, THRESHOLDS.API_CALL); + }); + + test('getServerInfo performance', async ({ page }) => { + const { duration } = await measureAsync(async () => { + await callAPI(page, 'getServerInfo', {}); + }); + recordMetric('API: getServerInfo', duration, THRESHOLDS.API_CALL); + }); + + test('listWebhooks performance', async ({ page }) => { + const { duration } = await measureAsync(async () => { + await callAPI(page, 'listWebhooks', { include_test: false }); + }); + recordMetric('API: listWebhooks', duration, THRESHOLDS.API_CALL); + }); + + test('getPreferences performance', async ({ page }) => { + const { duration } = await measureAsync(async () => { + await callAPI(page, 'getPreferences', {}); + }); + recordMetric('API: getPreferences', duration, THRESHOLDS.API_CALL); + }); +}); + +// ============================================================================= +// 4. MEETING DETAIL PERFORMANCE +// ============================================================================= + +test.describe('Meeting Detail Performance', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test('meeting detail with segments load', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + // Get first meeting + const meetings = await callAPI<{ meetings: { id: string }[] }>(page, 'listMeetings', { + limit: 1, + }); + + if (meetings.meetings.length === 0) { + test.skip(true, 'No meetings available'); + return; + } + + const meetingId = meetings.meetings[0].id; + + const { duration } = await measureAsync(async () => { + await callAPI(page, 'getMeeting', { + meeting_id: meetingId, + include_segments: true, + include_summary: true, + }); + }); + recordMetric('API: getMeeting (with segments)', duration, THRESHOLDS.API_CALL); + }); + + test('meeting detail page render', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const meetings = await callAPI<{ meetings: { id: string }[] }>(page, 'listMeetings', { + limit: 1, + }); + + if (meetings.meetings.length === 0) { + test.skip(true, 'No meetings available'); + return; + } + + const meetingId = meetings.meetings[0].id; + + const { duration } = await measureAsync(async () => { + await page.goto(`/meetings/${meetingId}`); + await waitForLoadingComplete(page); + }); + recordMetric('Page Load: Meeting Detail', duration, THRESHOLDS.PAGE_LOAD); + }); +}); + +// ============================================================================= +// 5. POST-PROCESSING PERFORMANCE (Diarization, Summary, Entities) +// ============================================================================= + +test.describe('Post-Processing Performance', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + }); + + test('getDiarizationJobStatus performance', async ({ page }) => { + const meetings = await callAPI<{ meetings: { id: string }[] }>(page, 'listMeetings', { + limit: 1, + }); + + if (meetings.meetings.length === 0) { + test.skip(true, 'No meetings available'); + return; + } + + const meetingId = meetings.meetings[0].id; + + const { duration } = await measureAsync(async () => { + await callAPI(page, 'getDiarizationJobStatus', { meeting_id: meetingId }); + }); + recordMetric('API: getDiarizationJobStatus', duration, THRESHOLDS.API_CALL); + }); + + test('refineSpeakers performance (if available)', async ({ page }) => { + const meetings = await callAPI<{ meetings: { id: string; state: string }[] }>( + page, + 'listMeetings', + { limit: 10, states: ['completed'] } + ); + + const completedMeeting = meetings.meetings.find((m) => m.state === 'completed'); + if (!completedMeeting) { + test.skip(true, 'No completed meetings available'); + return; + } + + const { duration } = await measureAsync(async () => { + try { + await callAPI(page, 'refineSpeakers', { meeting_id: completedMeeting.id }); + } catch { + // May fail if already refined or no audio + } + }); + recordMetric('API: refineSpeakers', duration, THRESHOLDS.HEAVY_COMPUTATION); + }); + + test('generateSummary performance (if available)', async ({ page }) => { + const meetings = await callAPI<{ meetings: { id: string; state: string }[] }>( + page, + 'listMeetings', + { limit: 10, states: ['completed'] } + ); + + const completedMeeting = meetings.meetings.find((m) => m.state === 'completed'); + if (!completedMeeting) { + test.skip(true, 'No completed meetings available'); + return; + } + + const { duration } = await measureAsync(async () => { + try { + await callAPI(page, 'generateSummary', { meeting_id: completedMeeting.id }); + } catch { + // May fail if already generated + } + }); + recordMetric('API: generateSummary', duration, THRESHOLDS.HEAVY_COMPUTATION); + }); + + test('extractEntities performance (if available)', async ({ page }) => { + const meetings = await callAPI<{ meetings: { id: string; state: string }[] }>( + page, + 'listMeetings', + { limit: 10, states: ['completed'] } + ); + + const completedMeeting = meetings.meetings.find((m) => m.state === 'completed'); + if (!completedMeeting) { + test.skip(true, 'No completed meetings available'); + return; + } + + const { duration } = await measureAsync(async () => { + try { + await callAPI(page, 'extractEntities', { meeting_id: completedMeeting.id }); + } catch { + // May fail if already extracted + } + }); + recordMetric('API: extractEntities', duration, THRESHOLDS.HEAVY_COMPUTATION); + }); +}); + +// ============================================================================= +// 6. PLAYBACK PERFORMANCE +// ============================================================================= + +test.describe('Playback Performance', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test('startPlayback performance', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const meetings = await callAPI<{ meetings: { id: string; state: string }[] }>( + page, + 'listMeetings', + { limit: 10, states: ['completed'] } + ); + + const completedMeeting = meetings.meetings.find((m) => m.state === 'completed'); + if (!completedMeeting) { + test.skip(true, 'No completed meetings available'); + return; + } + + const { duration } = await measureAsync(async () => { + try { + await callAPI(page, 'startPlayback', { + meeting_id: completedMeeting.id, + position: 0, + }); + } catch { + // May fail if no audio + } + }); + recordMetric('API: startPlayback', duration, THRESHOLDS.API_CALL); + }); + + test('seekPlayback performance', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const meetings = await callAPI<{ meetings: { id: string; state: string }[] }>( + page, + 'listMeetings', + { limit: 10, states: ['completed'] } + ); + + const completedMeeting = meetings.meetings.find((m) => m.state === 'completed'); + if (!completedMeeting) { + test.skip(true, 'No completed meetings available'); + return; + } + + // Start playback first + try { + await callAPI(page, 'startPlayback', { + meeting_id: completedMeeting.id, + position: 0, + }); + } catch { + test.skip(true, 'Cannot start playback'); + return; + } + + const { duration } = await measureAsync(async () => { + await callAPI(page, 'seekPlayback', { position: 5000 }); + }); + recordMetric('API: seekPlayback', duration, THRESHOLDS.REAL_TIME_UPDATE); + }); +}); + +// ============================================================================= +// 7. ASSISTANT PERFORMANCE +// ============================================================================= + +test.describe('Assistant Performance', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test('askAssistant performance', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const meetings = await callAPI<{ meetings: { id: string }[] }>(page, 'listMeetings', { + limit: 1, + }); + + if (meetings.meetings.length === 0) { + test.skip(true, 'No meetings available'); + return; + } + + const meetingId = meetings.meetings[0].id; + + const { duration } = await measureAsync(async () => { + try { + await callAPI(page, 'askAssistant', { + question: 'What was discussed?', + meeting_id: meetingId, + }); + } catch { + // May timeout or fail + } + }); + // Assistant has longer timeout - 20s + recordMetric('API: askAssistant', duration, 20000); + }); +}); + +// ============================================================================= +// 8. ANNOTATIONS PERFORMANCE +// ============================================================================= + +test.describe('Annotations Performance', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test('listAnnotations performance', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const meetings = await callAPI<{ meetings: { id: string }[] }>(page, 'listMeetings', { + limit: 1, + }); + + if (meetings.meetings.length === 0) { + test.skip(true, 'No meetings available'); + return; + } + + const meetingId = meetings.meetings[0].id; + + const { duration } = await measureAsync(async () => { + await callAPI(page, 'listAnnotations', { meeting_id: meetingId }); + }); + recordMetric('API: listAnnotations', duration, THRESHOLDS.API_CALL); + }); +}); + +// ============================================================================= +// 9. EXPORT PERFORMANCE +// ============================================================================= + +test.describe('Export Performance', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + const formats = ['markdown', 'text', 'json']; + + for (const format of formats) { + test(`exportTranscript (${format}) performance`, async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const meetings = await callAPI<{ meetings: { id: string; state: string }[] }>( + page, + 'listMeetings', + { limit: 10, states: ['completed'] } + ); + + const completedMeeting = meetings.meetings.find((m) => m.state === 'completed'); + if (!completedMeeting) { + test.skip(true, 'No completed meetings available'); + return; + } + + const { duration } = await measureAsync(async () => { + await callAPI(page, 'exportTranscript', { + meeting_id: completedMeeting.id, + format, + }); + }); + recordMetric(`API: exportTranscript (${format})`, duration, THRESHOLDS.API_CALL); + }); + } +}); + +// ============================================================================= +// 10. SETTINGS PERFORMANCE +// ============================================================================= + +test.describe('Settings Performance', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test('settings tabs load performance', async ({ page }) => { + await navigateTo(page, '/settings'); + await waitForLoadingComplete(page); + + const tabs = ['status', 'audio', 'ai', 'integrations']; + + for (const tab of tabs) { + const tabButton = page.locator(`[data-testid="settings-tab-${tab}"], button:has-text("${tab}")`).first(); + if (await tabButton.isVisible()) { + const { duration } = await measureAsync(async () => { + await tabButton.click(); + await page.waitForTimeout(100); // Allow tab content to render + }); + recordMetric(`Settings Tab: ${tab}`, duration, THRESHOLDS.NAVIGATION); + } + } + }); + + test('listAudioDevices performance', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const { duration } = await measureAsync(async () => { + await callAPI(page, 'listAudioDevices', {}); + }); + recordMetric('API: listAudioDevices', duration, THRESHOLDS.API_CALL); + }); + + test('getCloudConsentStatus performance', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const { duration } = await measureAsync(async () => { + await callAPI(page, 'getCloudConsentStatus', {}); + }); + recordMetric('API: getCloudConsentStatus', duration, THRESHOLDS.API_CALL); + }); + + test('getTriggerStatus performance', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const { duration } = await measureAsync(async () => { + await callAPI(page, 'getTriggerStatus', {}); + }); + recordMetric('API: getTriggerStatus', duration, THRESHOLDS.API_CALL); + }); +}); + +// ============================================================================= +// 11. BULK OPERATIONS PERFORMANCE +// ============================================================================= + +test.describe('Bulk Operations Performance', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test('concurrent API calls performance', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const { duration } = await measureAsync(async () => { + await Promise.all([ + callAPI(page, 'listMeetings', { limit: 20 }), + callAPI(page, 'listSpeakerStats', { workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID }), + callAPI(page, 'listTasks', { workspace_id: TEST_DATA.DEFAULT_WORKSPACE_ID, limit: 20 }), + callAPI(page, 'getServerInfo', {}), + ]); + }); + recordMetric('Concurrent: 4 API calls', duration, THRESHOLDS.API_CALL * 2); + }); + + test('sequential page navigation performance', async ({ page }) => { + const paths = ['/', '/meetings', '/tasks', '/people', '/analytics', '/settings']; + + const { duration } = await measureAsync(async () => { + for (const path of paths) { + await page.goto(path); + await waitForLoadingComplete(page); + } + }); + recordMetric(`Sequential: ${paths.length} page navigations`, duration, THRESHOLDS.PAGE_LOAD * paths.length); + }); +}); + +// ============================================================================= +// 12. MEMORY AND RESOURCE USAGE +// ============================================================================= + +test.describe('Memory and Resource Usage', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable'); + + test('memory usage after heavy navigation', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + // Perform heavy navigation + for (let i = 0; i < 5; i++) { + await page.goto('/meetings'); + await waitForLoadingComplete(page); + await page.goto('/analytics'); + await waitForLoadingComplete(page); + await page.goto('/people'); + await waitForLoadingComplete(page); + } + + // Check for memory leaks via performance API + const metrics = await page.evaluate(() => { + const perf = performance as Performance & { + memory?: { usedJSHeapSize: number; totalJSHeapSize: number }; + }; + if (perf.memory) { + return { + usedJSHeapSize: perf.memory.usedJSHeapSize, + totalJSHeapSize: perf.memory.totalJSHeapSize, + }; + } + return null; + }); + + if (metrics) { + const usedMB = metrics.usedJSHeapSize / (1024 * 1024); + const totalMB = metrics.totalJSHeapSize / (1024 * 1024); + console.log(`[PERF] Memory: ${usedMB.toFixed(1)}MB used / ${totalMB.toFixed(1)}MB total`); + // Flag if memory usage exceeds 200MB + expect(usedMB).toBeLessThan(200); + } + }); +}); + +// ============================================================================= +// FINAL SUMMARY +// ============================================================================= + +test.afterAll(() => { + console.log('\n========================================'); + console.log('PERFORMANCE PROFILING SUMMARY'); + console.log('========================================\n'); + + const passed = metrics.filter((m) => m.passed); + const failed = metrics.filter((m) => !m.passed); + + console.log(`Total metrics: ${metrics.length}`); + console.log(`Passed: ${passed.length}`); + console.log(`Failed: ${failed.length}`); + + if (failed.length > 0) { + console.log('\n--- FAILED METRICS ---'); + for (const m of failed) { + console.log(` ✗ ${m.name}: ${m.duration.toFixed(0)}ms (threshold: ${m.threshold}ms)`); + } + } + + console.log('\n--- ALL METRICS ---'); + for (const m of metrics) { + const status = m.passed ? '✓' : '✗'; + console.log(` ${status} ${m.name}: ${m.duration.toFixed(0)}ms (threshold: ${m.threshold}ms)`); + } +}); diff --git a/client/e2e/streaming-profile.spec.ts b/client/e2e/streaming-profile.spec.ts new file mode 100644 index 0000000..4ba378b --- /dev/null +++ b/client/e2e/streaming-profile.spec.ts @@ -0,0 +1,619 @@ +/** + * Streaming Flow Performance Profiling + * + * Profiles recording, transcription, and diarization streaming flows. + * Tests the full audio pipeline from capture to transcript display. + * + * Run with: + * NOTEFLOW_E2E=1 NOTEFLOW_E2E_BASE_URL=http://192.168.50.151:5173 npx playwright test streaming-profile.spec.ts + */ + +import { test, expect, type Page } from '@playwright/test'; +import { + waitForAppReady, + navigateTo, + waitForAPI, + callAPI, + E2E_TIMEOUTS, +} from './fixtures'; + +// Streaming profiling thresholds (milliseconds) +const STREAMING_THRESHOLDS = { + // Recording operations + startRecording: 2000, + stopRecording: 3000, + pauseRecording: 500, + resumeRecording: 1000, + + // Audio operations + audioDeviceList: 1000, + audioDeviceSelect: 500, + audioLevelResponse: 100, + + // Streaming operations + streamInit: 2000, + chunkProcess: 500, + transcriptUpdate: 1000, + + // UI responsiveness + recordingUIUpdate: 200, + timerUpdate: 100, + vuMeterUpdate: 50, +}; + +interface ProfileResult { + operation: string; + duration: number; + threshold: number; + passed: boolean; + details?: string; +} + +class StreamingProfiler { + private results: ProfileResult[] = []; + + async profile( + operation: string, + threshold: number, + fn: () => Promise, + details?: string + ): Promise { + const start = performance.now(); + try { + const result = await fn(); + const duration = performance.now() - start; + this.results.push({ + operation, + duration, + threshold, + passed: duration <= threshold, + details, + }); + return result; + } catch (error) { + const duration = performance.now() - start; + this.results.push({ + operation, + duration, + threshold, + passed: false, + details: `Error: ${error}`, + }); + throw error; + } + } + + getResults(): ProfileResult[] { + return this.results; + } + + getSummary(): string { + const passed = this.results.filter((r) => r.passed).length; + const total = this.results.length; + let summary = `\n${'='.repeat(60)}\nSTREAMING PROFILE SUMMARY\n${'='.repeat(60)}\n`; + summary += `Results: ${passed}/${total} passed thresholds\n\n`; + + for (const result of this.results) { + const status = result.passed ? '✓' : '✗'; + summary += `${status} ${result.operation}: ${result.duration.toFixed(2)}ms (threshold: ${result.threshold}ms)`; + if (result.details) { + summary += ` - ${result.details}`; + } + summary += '\n'; + } + + if (this.results.length > 0) { + const durations = this.results.map((r) => r.duration); + summary += `\nStatistics:\n`; + summary += ` Mean: ${(durations.reduce((a, b) => a + b, 0) / durations.length).toFixed(2)}ms\n`; + summary += ` Min: ${Math.min(...durations).toFixed(2)}ms\n`; + summary += ` Max: ${Math.max(...durations).toFixed(2)}ms\n`; + } + + return summary; + } +} + +test.describe('Recording Flow Profiling', () => { + let profiler: StreamingProfiler; + + test.beforeEach(async ({ page }) => { + profiler = new StreamingProfiler(); + await navigateTo(page, '/'); + await waitForAppReady(page); + await waitForAPI(page, E2E_TIMEOUTS.PAGE_LOAD_MS); + }); + + test.afterEach(async () => { + console.log(profiler.getSummary()); + }); + + test('should profile audio device enumeration', async ({ page }) => { + await profiler.profile( + 'audio_device_list', + STREAMING_THRESHOLDS.audioDeviceList, + async () => { + const devices = await callAPI<{ devices: unknown[] }>(page, 'listAudioDevices'); + return devices; + }, + 'List available audio devices' + ); + + const results = profiler.getResults(); + expect(results[0].passed).toBe(true); + }); + + test('should profile recording page load', async ({ page }) => { + await profiler.profile( + 'recording_page_load', + 3000, + async () => { + await page.goto('/recording/new'); + await page.waitForSelector('[data-testid="recording-controls"], [data-testid="start-recording-btn"]', { + timeout: 5000, + }); + }, + 'Load recording page with controls' + ); + + const results = profiler.getResults(); + expect(results[0].passed).toBe(true); + }); + + test('should profile recording UI state changes', async ({ page }) => { + await page.goto('/recording/new'); + await waitForAppReady(page); + + // Profile initial state render + await profiler.profile( + 'initial_state_render', + STREAMING_THRESHOLDS.recordingUIUpdate, + async () => { + await page.waitForSelector('[data-testid="recording-status"]', { timeout: 2000 }).catch(() => null); + }, + 'Render initial recording state' + ); + + // Profile control button responsiveness + const startButton = page.locator('[data-testid="start-recording-btn"], button:has-text("Start")').first(); + if (await startButton.isVisible()) { + await profiler.profile( + 'button_hover_response', + 50, + async () => { + await startButton.hover(); + await page.waitForTimeout(10); + }, + 'UI response to hover' + ); + } + + console.log(profiler.getSummary()); + }); +}); + +test.describe('Transcription Flow Profiling', () => { + let profiler: StreamingProfiler; + let meetingId: string | null = null; + + test.beforeEach(async ({ page }) => { + profiler = new StreamingProfiler(); + await navigateTo(page, '/'); + await waitForAppReady(page); + await waitForAPI(page, E2E_TIMEOUTS.PAGE_LOAD_MS); + }); + + test.afterEach(async ({ page }) => { + console.log(profiler.getSummary()); + + // Cleanup + if (meetingId) { + try { + await callAPI(page, 'deleteMeeting', meetingId); + } catch { + // Ignore cleanup errors + } + } + }); + + test('should profile meeting creation for transcription', async ({ page }) => { + const meeting = await profiler.profile( + 'create_meeting_for_transcription', + 1000, + async () => { + return callAPI<{ id: string }>(page, 'createMeeting', { + title: `Streaming Profile Test ${Date.now()}`, + }); + }, + 'Create meeting to receive transcription' + ); + + meetingId = meeting?.id ?? null; + expect(profiler.getResults()[0].passed).toBe(true); + }); + + test('should profile segment operations', async ({ page }) => { + // Create meeting first + const meeting = await callAPI<{ id: string }>(page, 'createMeeting', { + title: `Segment Profile Test ${Date.now()}`, + }); + meetingId = meeting?.id ?? null; + + if (!meetingId) { + test.skip(); + return; + } + + // Profile segment listing (empty) + await profiler.profile( + 'list_segments_empty', + 500, + async () => { + return callAPI(page, 'listSegments', meetingId); + }, + 'List segments from empty meeting' + ); + + // Profile meeting with segments (if any exist) + const meetings = await callAPI<{ meetings: Array<{ id: string }> }>(page, 'listMeetings', { limit: 5 }); + const existingMeetingId = meetings.meetings.find((m) => m.id !== meetingId)?.id; + + if (existingMeetingId) { + await profiler.profile( + 'list_segments_existing', + 1000, + async () => { + return callAPI(page, 'listSegments', existingMeetingId); + }, + 'List segments from existing meeting' + ); + } + + console.log(profiler.getSummary()); + }); +}); + +test.describe('Diarization Flow Profiling', () => { + let profiler: StreamingProfiler; + + test.beforeEach(async ({ page }) => { + profiler = new StreamingProfiler(); + await navigateTo(page, '/'); + await waitForAppReady(page); + await waitForAPI(page, E2E_TIMEOUTS.PAGE_LOAD_MS); + }); + + test.afterEach(async () => { + console.log(profiler.getSummary()); + }); + + test('should profile diarization status check', async ({ page }) => { + // Get a meeting with potential diarization + const meetings = await callAPI<{ meetings: Array<{ id: string }> }>(page, 'listMeetings', { limit: 1 }); + const meetingId = meetings.meetings[0]?.id; + + if (!meetingId) { + test.skip(); + return; + } + + await profiler.profile( + 'get_diarization_status', + 1000, + async () => { + try { + return await callAPI(page, 'getDiarizationStatus', meetingId); + } catch { + return { status: 'not_started' }; + } + }, + 'Check diarization job status' + ); + + expect(profiler.getResults()[0].duration).toBeLessThan(STREAMING_THRESHOLDS.transcriptUpdate); + }); + + test('should profile speaker listing', async ({ page }) => { + const meetings = await callAPI<{ meetings: Array<{ id: string }> }>(page, 'listMeetings', { limit: 1 }); + const meetingId = meetings.meetings[0]?.id; + + if (!meetingId) { + test.skip(); + return; + } + + await profiler.profile( + 'list_speakers', + 1000, + async () => { + try { + const segments = await callAPI<{ segments: Array<{ speaker?: string }> }>( + page, + 'listSegments', + meetingId + ); + const speakers = new Set(segments.segments.map((s) => s.speaker).filter(Boolean)); + return { speakers: Array.from(speakers) }; + } catch { + return { speakers: [] }; + } + }, + 'Extract unique speakers from segments' + ); + + console.log(profiler.getSummary()); + }); +}); + +test.describe('Real-time Event Profiling', () => { + let profiler: StreamingProfiler; + + test.beforeEach(async ({ page }) => { + profiler = new StreamingProfiler(); + await navigateTo(page, '/'); + await waitForAppReady(page); + }); + + test.afterEach(async () => { + console.log(profiler.getSummary()); + }); + + test('should profile event listener setup', async ({ page }) => { + await profiler.profile( + 'event_listener_setup', + 500, + async () => { + // Simulate setting up event listeners + await page.evaluate(() => { + return new Promise((resolve) => { + const listeners: Array<() => void> = []; + const events = [ + 'TRANSCRIPT_UPDATE', + 'AUDIO_LEVEL', + 'RECORDING_TIMER', + 'DIARIZATION_UPDATE', + 'CONNECTION_CHANGE', + ]; + + events.forEach((event) => { + const handler = () => {}; + listeners.push(handler); + }); + + resolve(); + }); + }); + }, + 'Set up real-time event listeners' + ); + + expect(profiler.getResults()[0].passed).toBe(true); + }); + + test('should profile connection state transitions', async ({ page }) => { + await profiler.profile( + 'connection_state_read', + 100, + async () => { + return page.evaluate(() => { + const conn = (window as Record).__NOTEFLOW_CONNECTION__ as { + getConnectionState?: () => unknown; + }; + return conn?.getConnectionState?.() ?? null; + }); + }, + 'Read current connection state' + ); + + expect(profiler.getResults()[0].passed).toBe(true); + }); +}); + +test.describe('Concurrent Streaming Operations', () => { + let profiler: StreamingProfiler; + + test.beforeEach(async ({ page }) => { + profiler = new StreamingProfiler(); + await navigateTo(page, '/'); + await waitForAppReady(page); + await waitForAPI(page, E2E_TIMEOUTS.PAGE_LOAD_MS); + }); + + test.afterEach(async () => { + console.log(profiler.getSummary()); + }); + + test('should profile concurrent API calls during streaming', async ({ page }) => { + await profiler.profile( + 'concurrent_api_burst', + 2000, + async () => { + // Simulate concurrent calls that might happen during streaming + const calls = await Promise.all([ + callAPI(page, 'listMeetings', { limit: 5 }), + callAPI(page, 'getPreferences'), + callAPI(page, 'listAudioDevices').catch(() => ({ devices: [] })), + ]); + return calls; + }, + '3 concurrent API calls' + ); + + expect(profiler.getResults()[0].passed).toBe(true); + }); + + test('should profile rapid sequential operations', async ({ page }) => { + const iterations = 5; + + await profiler.profile( + 'rapid_sequential_reads', + 1500, + async () => { + const results: unknown[] = []; + for (let i = 0; i < iterations; i++) { + const result = await callAPI(page, 'listMeetings', { limit: 1 }); + results.push(result); + } + return results; + }, + `${iterations} sequential meeting list calls` + ); + + const result = profiler.getResults()[0]; + console.log(`Average per call: ${(result.duration / iterations).toFixed(2)}ms`); + expect(result.passed).toBe(true); + }); +}); + +test.describe('UI Performance During Streaming', () => { + let profiler: StreamingProfiler; + + test.beforeEach(async ({ page }) => { + profiler = new StreamingProfiler(); + await navigateTo(page, '/'); + await waitForAppReady(page); + }); + + test.afterEach(async () => { + console.log(profiler.getSummary()); + }); + + test('should profile navigation during potential streaming', async ({ page }) => { + const routes = ['/meetings', '/recording/new', '/settings', '/']; + + for (const route of routes) { + await profiler.profile( + `navigate_to_${route.replace(/\//g, '_') || 'home'}`, + 3000, + async () => { + await page.goto(route); + await waitForAppReady(page); + }, + `Navigate to ${route}` + ); + } + + const failed = profiler.getResults().filter((r) => !r.passed); + expect(failed.length).toBe(0); + }); + + test('should profile component render times', async ({ page }) => { + await page.goto('/meetings'); + await waitForAppReady(page); + + // Profile meeting card render + await profiler.profile( + 'meeting_list_render', + 2000, + async () => { + await page.waitForSelector('[data-testid="meeting-card"], [data-testid="meetings-list"]', { + timeout: 5000, + }).catch(() => null); + }, + 'Render meeting list component' + ); + + console.log(profiler.getSummary()); + }); +}); + +test.describe('Streaming Pipeline Simulation', () => { + let profiler: StreamingProfiler; + let createdMeetingId: string | null = null; + + test.beforeEach(async ({ page }) => { + profiler = new StreamingProfiler(); + await navigateTo(page, '/'); + await waitForAppReady(page); + await waitForAPI(page, E2E_TIMEOUTS.PAGE_LOAD_MS); + }); + + test.afterEach(async ({ page }) => { + console.log(profiler.getSummary()); + + if (createdMeetingId) { + try { + await callAPI(page, 'deleteMeeting', createdMeetingId); + } catch { + // Ignore + } + } + }); + + test('should profile full streaming pipeline stages', async ({ page }) => { + // Stage 1: Create meeting + const meeting = await profiler.profile( + 'pipeline_stage_1_create_meeting', + 1000, + async () => { + return callAPI<{ id: string }>(page, 'createMeeting', { + title: `Pipeline Test ${Date.now()}`, + }); + }, + 'Stage 1: Create meeting for streaming' + ); + createdMeetingId = meeting?.id ?? null; + + if (!createdMeetingId) { + test.skip(); + return; + } + + // Stage 2: Get meeting (verify creation) + await profiler.profile( + 'pipeline_stage_2_get_meeting', + 500, + async () => { + return callAPI(page, 'getMeeting', createdMeetingId); + }, + 'Stage 2: Verify meeting exists' + ); + + // Stage 3: List segments (empty initially) + await profiler.profile( + 'pipeline_stage_3_list_segments', + 500, + async () => { + return callAPI(page, 'listSegments', createdMeetingId); + }, + 'Stage 3: List initial segments' + ); + + // Stage 4: Simulate annotation creation (like real-time notes) + await profiler.profile( + 'pipeline_stage_4_create_annotation', + 500, + async () => { + try { + return await callAPI(page, 'createAnnotation', { + meetingId: createdMeetingId, + type: 'action_item', + text: 'Pipeline test annotation', + }); + } catch { + return { id: 'skipped' }; + } + }, + 'Stage 4: Create real-time annotation' + ); + + // Stage 5: List annotations + await profiler.profile( + 'pipeline_stage_5_list_annotations', + 500, + async () => { + try { + return await callAPI(page, 'listAnnotations', createdMeetingId); + } catch { + return { annotations: [] }; + } + }, + 'Stage 5: List annotations' + ); + + // Verify all stages passed + const results = profiler.getResults(); + const passed = results.filter((r) => r.passed).length; + console.log(`Pipeline stages passed: ${passed}/${results.length}`); + }); +}); diff --git a/client/package-lock.json b/client/package-lock.json index 4a847b4..cc81c7d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -491,6 +491,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -532,6 +533,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -553,6 +555,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1219,6 +1222,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" @@ -4591,6 +4595,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz", "integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4813,6 +4818,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.15.3.tgz", "integrity": "sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4957,6 +4963,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.15.3.tgz", "integrity": "sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4971,6 +4978,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz", "integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -5070,8 +5078,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", @@ -5227,6 +5234,7 @@ "integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5249,6 +5257,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -5259,6 +5268,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -5373,6 +5383,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -5837,6 +5848,7 @@ "integrity": "sha512-OmwPKV8c5ecLqo+EkytN7oUeYfNmRI4uOXGIR1ybP7AK5Zz+l9R0dGfoadEuwi1aZXAL0vwuhtq3p0OL3dfqHQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18.20.0" }, @@ -5891,6 +5903,7 @@ "integrity": "sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^5.1.2", "loglevel": "^1.6.0", @@ -6141,6 +6154,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6648,6 +6662,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7414,6 +7429,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -7558,8 +7574,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -7770,7 +7785,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -7958,6 +7974,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8294,6 +8311,7 @@ "integrity": "sha512-gQHqfI6SmtYBIkTeMizpHThdpXh6ej2Hk68oKZneFM6iu99ZGXvOPnmhd8VDus3xOWhVDDdf4sLsMV2/o+X6Yg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/snapshot": "^4.0.16", "deep-eql": "^5.0.2", @@ -9691,6 +9709,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -9719,6 +9738,7 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -10179,7 +10199,6 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11191,6 +11210,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11357,7 +11377,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11539,6 +11558,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -11568,6 +11588,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -11616,6 +11637,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.5.tgz", "integrity": "sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -11730,6 +11752,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11756,6 +11779,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11769,6 +11793,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -11784,8 +11809,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-remove-scroll": { "version": "2.7.2", @@ -13031,6 +13055,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -13922,6 +13947,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14157,6 +14183,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14923,6 +14950,7 @@ "integrity": "sha512-Y5y4jpwHvuduUfup+gXTuCU6AROn/k6qOba3st0laFluKHY+q5SHOpQAJdS8acYLwE8caDQ2dXJhmXyxuJrm0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", diff --git a/tests/profiling/__init__.py b/tests/profiling/__init__.py new file mode 100644 index 0000000..c67edcb --- /dev/null +++ b/tests/profiling/__init__.py @@ -0,0 +1 @@ +"""Backend performance profiling tests.""" diff --git a/tests/profiling/conftest.py b/tests/profiling/conftest.py new file mode 100644 index 0000000..ad99b40 --- /dev/null +++ b/tests/profiling/conftest.py @@ -0,0 +1,13 @@ +"""Pytest configuration for profiling tests.""" + +from __future__ import annotations + +import pytest + + +def pytest_configure(config: pytest.Config) -> None: + """Register profiling markers.""" + config.addinivalue_line( + "markers", + "profiling: marks tests as profiling tests (deselect with '-m \"not profiling\"')", + ) diff --git a/tests/profiling/test_backend_roundtrip_profile.py b/tests/profiling/test_backend_roundtrip_profile.py new file mode 100644 index 0000000..da8cee8 --- /dev/null +++ b/tests/profiling/test_backend_roundtrip_profile.py @@ -0,0 +1,649 @@ +"""Backend gRPC Round-Trip Performance Profiling. + +Profiles real backend operations including database round-trips. +Configure NOTEFLOW_GRPC_TARGET to connect to remote server. + +Run with: + NOTEFLOW_GRPC_TARGET=192.168.50.151:50051 pytest tests/profiling/test_backend_roundtrip_profile.py -v -s +""" + +from __future__ import annotations + +import asyncio +import os +import statistics +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Final +from uuid import uuid4 + +import grpc +import pytest + +from noteflow.grpc.proto import noteflow_pb2, noteflow_pb2_grpc + +if TYPE_CHECKING: + from collections.abc import Generator + +# Configuration +DEFAULT_SERVER: Final[str] = "localhost:50051" +GRPC_TARGET: Final[str] = os.environ.get("NOTEFLOW_GRPC_TARGET", DEFAULT_SERVER) + +# Required gRPC metadata +def get_metadata() -> tuple[tuple[str, str], ...]: + """Get required gRPC metadata headers.""" + return ( + ("x-request-id", str(uuid4())), + ("x-workspace-id", "00000000-0000-0000-0000-000000000001"), + ) + +# Profiling thresholds (milliseconds) +THRESHOLDS = { + "create_meeting": 500, + "get_meeting": 200, + "list_meetings": 300, + "update_meeting": 300, + "delete_meeting": 300, + "create_segment": 200, + "list_segments": 300, + "diarization_refine": 5000, + "generate_summary": 10000, + "get_analytics": 1000, + "create_annotation": 200, + "list_annotations": 300, + "get_preferences": 100, + "save_preferences": 200, + "concurrent_reads": 500, + "batch_create": 2000, +} + + +@dataclass +class ProfileResult: + """Result of a profiled operation.""" + + operation: str + duration_ms: float + success: bool + threshold_ms: float + details: str = "" + + @property + def passed_threshold(self) -> bool: + return self.duration_ms <= self.threshold_ms + + def __str__(self) -> str: + status = "PASS" if self.passed_threshold else "FAIL" + return f"[{status}] {self.operation}: {self.duration_ms:.2f}ms (threshold: {self.threshold_ms}ms)" + + +@dataclass +class ProfileSession: + """Collection of profiling results.""" + + results: list[ProfileResult] = field(default_factory=list) + server: str = GRPC_TARGET + + def add(self, result: ProfileResult) -> None: + self.results.append(result) + + def summary(self) -> str: + lines = [f"\n{'='*60}", f"PROFILING SUMMARY - Server: {self.server}", "=" * 60] + passed = sum(1 for r in self.results if r.passed_threshold) + total = len(self.results) + lines.append(f"Results: {passed}/{total} passed thresholds\n") + + for result in self.results: + lines.append(str(result)) + + if self.results: + durations = [r.duration_ms for r in self.results] + lines.append(f"\nStatistics:") + lines.append(f" Mean: {statistics.mean(durations):.2f}ms") + lines.append(f" Median: {statistics.median(durations):.2f}ms") + lines.append(f" Stdev: {statistics.stdev(durations):.2f}ms" if len(durations) > 1 else "") + lines.append(f" Min: {min(durations):.2f}ms") + lines.append(f" Max: {max(durations):.2f}ms") + + return "\n".join(lines) + + +@pytest.fixture(scope="module") +def grpc_channel() -> Generator[grpc.Channel, None, None]: + """Create gRPC channel to the target server.""" + channel = grpc.insecure_channel( + GRPC_TARGET, + options=[ + ("grpc.keepalive_time_ms", 10000), + ("grpc.keepalive_timeout_ms", 5000), + ("grpc.keepalive_permit_without_calls", True), + ], + ) + # Wait for channel to be ready + try: + grpc.channel_ready_future(channel).result(timeout=10) + except grpc.FutureTimeoutError: + pytest.skip(f"Could not connect to gRPC server at {GRPC_TARGET}") + yield channel + channel.close() + + +@pytest.fixture(scope="module") +def stub(grpc_channel: grpc.Channel) -> noteflow_pb2_grpc.NoteFlowServiceStub: + """Create gRPC stub.""" + return noteflow_pb2_grpc.NoteFlowServiceStub(grpc_channel) + + +@pytest.fixture(scope="module") +def profile_session() -> ProfileSession: + """Create a profiling session.""" + return ProfileSession() + + +def profile_operation( + operation: str, threshold_ms: float | None = None +) -> callable: + """Decorator to profile an operation.""" + + def decorator(func: callable) -> callable: + def wrapper( + stub: noteflow_pb2_grpc.NoteFlowServiceStub, + profile_session: ProfileSession, + *args, + **kwargs, + ): + threshold = threshold_ms or THRESHOLDS.get(operation, 1000) + start = time.perf_counter() + try: + result = func(stub, profile_session, *args, **kwargs) + duration_ms = (time.perf_counter() - start) * 1000 + profile_result = ProfileResult( + operation=operation, + duration_ms=duration_ms, + success=True, + threshold_ms=threshold, + ) + profile_session.add(profile_result) + print(profile_result) + return result + except grpc.RpcError as e: + duration_ms = (time.perf_counter() - start) * 1000 + profile_result = ProfileResult( + operation=operation, + duration_ms=duration_ms, + success=False, + threshold_ms=threshold, + details=str(e.code()), + ) + profile_session.add(profile_result) + print(f"✗ {operation}: FAILED - {e.code()}") + raise + + return wrapper + + return decorator + + +class TestMeetingCRUDProfiling: + """Profile meeting CRUD operations.""" + + @pytest.fixture(autouse=True) + def _setup( + self, + stub: noteflow_pb2_grpc.NoteFlowServiceStub, + profile_session: ProfileSession, + ) -> None: + self.stub = stub + self.session = profile_session + self.meeting_id: str | None = None + + def test_01_create_meeting(self) -> None: + """Profile meeting creation.""" + start = time.perf_counter() + request = noteflow_pb2.CreateMeetingRequest( + title=f"Profile Test Meeting {uuid4()}", + ) + try: + response = self.stub.CreateMeeting(request, timeout=10, metadata=get_metadata()) + duration_ms = (time.perf_counter() - start) * 1000 + self.meeting_id = response.id + result = ProfileResult( + operation="create_meeting", + duration_ms=duration_ms, + success=True, + threshold_ms=THRESHOLDS["create_meeting"], + ) + self.session.add(result) + print(result) + # Store for subsequent tests + pytest.meeting_id = response.id + assert response.id + except grpc.RpcError as e: + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="create_meeting", + duration_ms=duration_ms, + success=False, + threshold_ms=THRESHOLDS["create_meeting"], + details=str(e.code()), + ) + self.session.add(result) + pytest.skip(f"Create meeting failed: {e.code()}") + + def test_02_get_meeting(self) -> None: + """Profile meeting retrieval.""" + meeting_id = getattr(pytest, "meeting_id", None) + if not meeting_id: + pytest.skip("No meeting created") + + start = time.perf_counter() + request = noteflow_pb2.GetMeetingRequest(meeting_id=meeting_id) + try: + response = self.stub.GetMeeting(request, timeout=10, metadata=get_metadata()) + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="get_meeting", + duration_ms=duration_ms, + success=True, + threshold_ms=THRESHOLDS["get_meeting"], + ) + self.session.add(result) + print(result) + assert response.id == meeting_id + except grpc.RpcError as e: + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="get_meeting", + duration_ms=duration_ms, + success=False, + threshold_ms=THRESHOLDS["get_meeting"], + details=str(e.code()), + ) + self.session.add(result) + raise + + def test_03_list_meetings(self) -> None: + """Profile meeting listing.""" + start = time.perf_counter() + request = noteflow_pb2.ListMeetingsRequest(limit=50) + try: + response = self.stub.ListMeetings(request, timeout=10, metadata=get_metadata()) + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="list_meetings", + duration_ms=duration_ms, + success=True, + threshold_ms=THRESHOLDS["list_meetings"], + details=f"count={len(response.meetings)}", + ) + self.session.add(result) + print(result) + except grpc.RpcError as e: + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="list_meetings", + duration_ms=duration_ms, + success=False, + threshold_ms=THRESHOLDS["list_meetings"], + details=str(e.code()), + ) + self.session.add(result) + raise + + def test_04_stop_meeting(self) -> None: + """Profile meeting stop operation.""" + meeting_id = getattr(pytest, "meeting_id", None) + if not meeting_id: + pytest.skip("No meeting created") + + start = time.perf_counter() + request = noteflow_pb2.StopMeetingRequest(meeting_id=meeting_id) + try: + response = self.stub.StopMeeting(request, timeout=10, metadata=get_metadata()) + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="stop_meeting", + duration_ms=duration_ms, + success=True, + threshold_ms=THRESHOLDS.get("stop_meeting", 500), + ) + self.session.add(result) + print(result) + except grpc.RpcError as e: + duration_ms = (time.perf_counter() - start) * 1000 + # Stop on non-recording meeting might fail, but we still capture timing + result = ProfileResult( + operation="stop_meeting", + duration_ms=duration_ms, + success=False, + threshold_ms=THRESHOLDS.get("stop_meeting", 500), + details=str(e.code()), + ) + self.session.add(result) + print(result) # Don't raise - expected to fail on non-recording meeting + + +class TestSegmentProfiling: + """Profile segment operations.""" + + @pytest.fixture(autouse=True) + def _setup( + self, + stub: noteflow_pb2_grpc.NoteFlowServiceStub, + profile_session: ProfileSession, + ) -> None: + self.stub = stub + self.session = profile_session + + def test_list_segments(self) -> None: + """Profile segment listing (via GetMeeting with include_segments).""" + meeting_id = getattr(pytest, "meeting_id", None) + if not meeting_id: + pytest.skip("No meeting created") + + start = time.perf_counter() + request = noteflow_pb2.GetMeetingRequest( + meeting_id=meeting_id, + include_segments=True, + ) + try: + response = self.stub.GetMeeting(request, timeout=10, metadata=get_metadata()) + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="list_segments", + duration_ms=duration_ms, + success=True, + threshold_ms=THRESHOLDS["list_segments"], + details=f"count={len(response.segments)}", + ) + self.session.add(result) + print(result) + except grpc.RpcError as e: + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="list_segments", + duration_ms=duration_ms, + success=False, + threshold_ms=THRESHOLDS["list_segments"], + details=str(e.code()), + ) + self.session.add(result) + raise + + +class TestAnnotationProfiling: + """Profile annotation operations.""" + + @pytest.fixture(autouse=True) + def _setup( + self, + stub: noteflow_pb2_grpc.NoteFlowServiceStub, + profile_session: ProfileSession, + ) -> None: + self.stub = stub + self.session = profile_session + + def test_01_create_annotation(self) -> None: + """Profile annotation creation.""" + meeting_id = getattr(pytest, "meeting_id", None) + if not meeting_id: + pytest.skip("No meeting created") + + start = time.perf_counter() + request = noteflow_pb2.AddAnnotationRequest( + meeting_id=meeting_id, + annotation_type=noteflow_pb2.ANNOTATION_TYPE_ACTION_ITEM, + text=f"Profile test annotation {uuid4()}", + ) + try: + response = self.stub.AddAnnotation(request, timeout=10, metadata=get_metadata()) + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="create_annotation", + duration_ms=duration_ms, + success=True, + threshold_ms=THRESHOLDS["create_annotation"], + ) + self.session.add(result) + print(result) + pytest.annotation_id = response.id + except grpc.RpcError as e: + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="create_annotation", + duration_ms=duration_ms, + success=False, + threshold_ms=THRESHOLDS["create_annotation"], + details=str(e.code()), + ) + self.session.add(result) + raise + + def test_02_list_annotations(self) -> None: + """Profile annotation listing.""" + meeting_id = getattr(pytest, "meeting_id", None) + if not meeting_id: + pytest.skip("No meeting created") + + start = time.perf_counter() + request = noteflow_pb2.ListAnnotationsRequest(meeting_id=meeting_id) + try: + response = self.stub.ListAnnotations(request, timeout=10, metadata=get_metadata()) + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="list_annotations", + duration_ms=duration_ms, + success=True, + threshold_ms=THRESHOLDS["list_annotations"], + details=f"count={len(response.annotations)}", + ) + self.session.add(result) + print(result) + except grpc.RpcError as e: + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="list_annotations", + duration_ms=duration_ms, + success=False, + threshold_ms=THRESHOLDS["list_annotations"], + details=str(e.code()), + ) + self.session.add(result) + raise + + +class TestPreferencesProfiling: + """Profile preferences operations.""" + + @pytest.fixture(autouse=True) + def _setup( + self, + stub: noteflow_pb2_grpc.NoteFlowServiceStub, + profile_session: ProfileSession, + ) -> None: + self.stub = stub + self.session = profile_session + + def test_get_preferences(self) -> None: + """Profile preferences retrieval.""" + start = time.perf_counter() + request = noteflow_pb2.GetPreferencesRequest() + try: + response = self.stub.GetPreferences(request, timeout=10, metadata=get_metadata()) + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="get_preferences", + duration_ms=duration_ms, + success=True, + threshold_ms=THRESHOLDS["get_preferences"], + ) + self.session.add(result) + print(result) + except grpc.RpcError as e: + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="get_preferences", + duration_ms=duration_ms, + success=False, + threshold_ms=THRESHOLDS["get_preferences"], + details=str(e.code()), + ) + self.session.add(result) + raise + + +class TestConcurrentProfiling: + """Profile concurrent operations.""" + + @pytest.fixture(autouse=True) + def _setup( + self, + stub: noteflow_pb2_grpc.NoteFlowServiceStub, + profile_session: ProfileSession, + ) -> None: + self.stub = stub + self.session = profile_session + + def test_concurrent_reads(self) -> None: + """Profile concurrent read operations.""" + import concurrent.futures + + def read_meetings(): + request = noteflow_pb2.ListMeetingsRequest(limit=10) + return self.stub.ListMeetings(request, timeout=10, metadata=get_metadata()) + + start = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(read_meetings) for _ in range(5)] + results = [f.result() for f in concurrent.futures.as_completed(futures)] + + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="concurrent_reads", + duration_ms=duration_ms, + success=True, + threshold_ms=THRESHOLDS["concurrent_reads"], + details=f"5 concurrent requests", + ) + self.session.add(result) + print(result) + + +class TestBatchProfiling: + """Profile batch operations.""" + + @pytest.fixture(autouse=True) + def _setup( + self, + stub: noteflow_pb2_grpc.NoteFlowServiceStub, + profile_session: ProfileSession, + ) -> None: + self.stub = stub + self.session = profile_session + + def test_batch_create_meetings(self) -> None: + """Profile batch meeting creation.""" + meeting_ids = [] + start = time.perf_counter() + + for i in range(10): + request = noteflow_pb2.CreateMeetingRequest( + title=f"Batch Profile Meeting {i} - {uuid4()}", + ) + try: + response = self.stub.CreateMeeting(request, timeout=10, metadata=get_metadata()) + meeting_ids.append(response.id) + except grpc.RpcError: + pass + + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="batch_create", + duration_ms=duration_ms, + success=len(meeting_ids) == 10, + threshold_ms=THRESHOLDS["batch_create"], + details=f"created={len(meeting_ids)}/10", + ) + self.session.add(result) + print(result) + + # Cleanup - delete created meetings + for mid in meeting_ids: + try: + request = noteflow_pb2.DeleteMeetingRequest(meeting_id=mid) + self.stub.DeleteMeeting(request, timeout=10, metadata=get_metadata()) + except grpc.RpcError: + pass + + +class TestAnalyticsProfiling: + """Profile analytics operations.""" + + @pytest.fixture(autouse=True) + def _setup( + self, + stub: noteflow_pb2_grpc.NoteFlowServiceStub, + profile_session: ProfileSession, + ) -> None: + self.stub = stub + self.session = profile_session + + def test_get_analytics(self) -> None: + """Profile analytics retrieval.""" + start = time.perf_counter() + request = noteflow_pb2.GetAnalyticsOverviewRequest() + try: + response = self.stub.GetAnalyticsOverview(request, timeout=30, metadata=get_metadata()) + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="get_analytics", + duration_ms=duration_ms, + success=True, + threshold_ms=THRESHOLDS["get_analytics"], + ) + self.session.add(result) + print(result) + except grpc.RpcError as e: + duration_ms = (time.perf_counter() - start) * 1000 + result = ProfileResult( + operation="get_analytics", + duration_ms=duration_ms, + success=False, + threshold_ms=THRESHOLDS["get_analytics"], + details=str(e.code()), + ) + self.session.add(result) + # Don't raise - analytics might not be implemented + print(f"Analytics: {e.code()} (may not be implemented)") + + +class TestCleanupAndSummary: + """Cleanup test data and print summary.""" + + @pytest.fixture(autouse=True) + def _setup( + self, + stub: noteflow_pb2_grpc.NoteFlowServiceStub, + profile_session: ProfileSession, + ) -> None: + self.stub = stub + self.session = profile_session + + def test_cleanup_and_summary(self) -> None: + """Cleanup created meeting and print summary.""" + meeting_id = getattr(pytest, "meeting_id", None) + if meeting_id: + try: + request = noteflow_pb2.DeleteMeetingRequest(meeting_id=meeting_id) + self.stub.DeleteMeeting(request, timeout=10, metadata=get_metadata()) + print(f"Cleaned up meeting: {meeting_id}") + except grpc.RpcError as e: + print(f"Cleanup failed: {e.code()}") + + # Print summary + print(self.session.summary()) + + # Assert all thresholds passed (optional - comment out for pure profiling) + failed = [r for r in self.session.results if not r.passed_threshold and r.success] + if failed: + print(f"\n[WARNING] {len(failed)} operations exceeded thresholds")