620 lines
16 KiB
TypeScript
620 lines
16 KiB
TypeScript
/**
|
|
* 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 } 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 unknown 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}`);
|
|
});
|
|
});
|