feat: add comprehensive performance profiling tests for frontend and backend operations, including round-trip, streaming, and bulk operations.
This commit is contained in:
979
client/e2e/full-roundtrip-profile.spec.ts
Normal file
979
client/e2e/full-roundtrip-profile.spec.ts
Normal file
@@ -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<T>(fn: () => Promise<T>): 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<string, RoundTripMetric[]>();
|
||||
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));
|
||||
});
|
||||
707
client/e2e/performance-profile.spec.ts
Normal file
707
client/e2e/performance-profile.spec.ts
Normal file
@@ -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<T>(fn: () => Promise<T>): 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)`);
|
||||
}
|
||||
});
|
||||
619
client/e2e/streaming-profile.spec.ts
Normal file
619
client/e2e/streaming-profile.spec.ts
Normal file
@@ -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<T>(
|
||||
operation: string,
|
||||
threshold: number,
|
||||
fn: () => Promise<T>,
|
||||
details?: string
|
||||
): Promise<T> {
|
||||
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<void>((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<string, unknown>).__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}`);
|
||||
});
|
||||
});
|
||||
46
client/package-lock.json
generated
46
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
1
tests/profiling/__init__.py
Normal file
1
tests/profiling/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend performance profiling tests."""
|
||||
13
tests/profiling/conftest.py
Normal file
13
tests/profiling/conftest.py
Normal file
@@ -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\"')",
|
||||
)
|
||||
649
tests/profiling/test_backend_roundtrip_profile.py
Normal file
649
tests/profiling/test_backend_roundtrip_profile.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user