Files
noteflow/client/e2e/streaming-profile.spec.ts
Travis Vasceannie 94d92b814f
Some checks failed
CI / test-python (push) Failing after 22m25s
CI / test-typescript (push) Failing after 5m56s
CI / test-rust (push) Failing after 6m56s
feat: centralize analytics service test fixtures and correct cache invalidation assertion logic.
2026-01-25 03:40:19 +00:00

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}`);
});
});