282 lines
7.8 KiB
JavaScript
282 lines
7.8 KiB
JavaScript
|
|
import http from 'k6/http';
|
||
|
|
import { check, sleep } from 'k6';
|
||
|
|
import { Rate, Trend, Counter } from 'k6/metrics';
|
||
|
|
import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
|
||
|
|
|
||
|
|
// Load environment variables
|
||
|
|
const API_ORIGIN = __ENV.API_ORIGIN || 'https://api.lab.veza';
|
||
|
|
const STREAM_ORIGIN = __ENV.STREAM_ORIGIN || 'https://stream.lab.veza';
|
||
|
|
const TEST_EMAIL_PREFIX = __ENV.TEST_EMAIL_PREFIX || 'user+stream';
|
||
|
|
const TEST_EMAIL_DOMAIN = __ENV.TEST_EMAIL_DOMAIN || 'lab.veza';
|
||
|
|
const TEST_PASSWORD_PREFIX = __ENV.TEST_PASSWORD_PREFIX || 'V3za!stream-';
|
||
|
|
|
||
|
|
// Custom metrics
|
||
|
|
const streamHealthDuration = new Trend('stream_health_duration');
|
||
|
|
const streamUploadDuration = new Trend('stream_upload_duration');
|
||
|
|
const streamPlayDuration = new Trend('stream_play_duration');
|
||
|
|
const streamErrorRate = new Rate('stream_errors');
|
||
|
|
const bytesStreamed = Counter('bytes_streamed');
|
||
|
|
|
||
|
|
// Test configuration
|
||
|
|
export const options = {
|
||
|
|
scenarios: {
|
||
|
|
stream_load: {
|
||
|
|
executor: 'constant-arrival-rate',
|
||
|
|
rate: 20, // 20 requests per second
|
||
|
|
timeUnit: '1s',
|
||
|
|
duration: '3m',
|
||
|
|
preAllocatedVUs: 50,
|
||
|
|
maxVUs: 100,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
thresholds: {
|
||
|
|
http_req_duration: ['p(95)<500', 'p(99)<1000'],
|
||
|
|
stream_health_duration: ['p(95)<100', 'p(99)<200'],
|
||
|
|
stream_play_duration: ['p(95)<300', 'p(99)<500'],
|
||
|
|
stream_errors: ['rate<0.01'], // Less than 1% error rate
|
||
|
|
http_req_failed: ['rate<0.01'],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Helper to create authenticated user
|
||
|
|
function createAuthenticatedUser() {
|
||
|
|
const user = {
|
||
|
|
email: `${TEST_EMAIL_PREFIX}${randomString(8)}@${TEST_EMAIL_DOMAIN}`,
|
||
|
|
password: `${TEST_PASSWORD_PREFIX}${randomString(8)}`,
|
||
|
|
};
|
||
|
|
|
||
|
|
const registerRes = http.post(`${API_ORIGIN}/auth/register`, JSON.stringify(user), {
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (registerRes.status !== 201) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const body = JSON.parse(registerRes.body);
|
||
|
|
return {
|
||
|
|
...user,
|
||
|
|
accessToken: body.access_token,
|
||
|
|
userId: body.user.id,
|
||
|
|
};
|
||
|
|
} catch (e) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate simple audio data (WAV header + silence)
|
||
|
|
function generateSimpleAudioData(durationSeconds = 10) {
|
||
|
|
const sampleRate = 44100;
|
||
|
|
const numChannels = 2;
|
||
|
|
const bitsPerSample = 16;
|
||
|
|
const dataSize = sampleRate * numChannels * (bitsPerSample / 8) * durationSeconds;
|
||
|
|
|
||
|
|
// Create WAV header
|
||
|
|
const header = new ArrayBuffer(44);
|
||
|
|
const view = new DataView(header);
|
||
|
|
|
||
|
|
// RIFF chunk descriptor
|
||
|
|
view.setUint32(0, 0x52494646, false); // "RIFF"
|
||
|
|
view.setUint32(4, 36 + dataSize, true); // File size - 8
|
||
|
|
view.setUint32(8, 0x57415645, false); // "WAVE"
|
||
|
|
|
||
|
|
// fmt sub-chunk
|
||
|
|
view.setUint32(12, 0x666d7420, false); // "fmt "
|
||
|
|
view.setUint32(16, 16, true); // Subchunk size
|
||
|
|
view.setUint16(20, 1, true); // Audio format (PCM)
|
||
|
|
view.setUint16(22, numChannels, true);
|
||
|
|
view.setUint32(24, sampleRate, true);
|
||
|
|
view.setUint32(28, sampleRate * numChannels * (bitsPerSample / 8), true); // Byte rate
|
||
|
|
view.setUint16(32, numChannels * (bitsPerSample / 8), true); // Block align
|
||
|
|
view.setUint16(34, bitsPerSample, true);
|
||
|
|
|
||
|
|
// data sub-chunk
|
||
|
|
view.setUint32(36, 0x64617461, false); // "data"
|
||
|
|
view.setUint32(40, dataSize, true);
|
||
|
|
|
||
|
|
// Create silence data
|
||
|
|
const silence = new ArrayBuffer(dataSize);
|
||
|
|
|
||
|
|
// Combine header and data
|
||
|
|
const audioBuffer = new Uint8Array(header.byteLength + silence.byteLength);
|
||
|
|
audioBuffer.set(new Uint8Array(header), 0);
|
||
|
|
audioBuffer.set(new Uint8Array(silence), header.byteLength);
|
||
|
|
|
||
|
|
return audioBuffer.buffer;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Setup: Create test users and sample audio
|
||
|
|
export function setup() {
|
||
|
|
const users = [];
|
||
|
|
const userCount = 30;
|
||
|
|
|
||
|
|
console.log(`Creating ${userCount} test users for streaming test...`);
|
||
|
|
|
||
|
|
for (let i = 0; i < userCount; i++) {
|
||
|
|
const user = createAuthenticatedUser();
|
||
|
|
if (user) {
|
||
|
|
users.push(user);
|
||
|
|
}
|
||
|
|
sleep(0.1);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`Created ${users.length} authenticated users`);
|
||
|
|
|
||
|
|
// Generate test audio data
|
||
|
|
const audioData = generateSimpleAudioData(10); // 10 second audio
|
||
|
|
|
||
|
|
return { users, audioData };
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function (data) {
|
||
|
|
const { users, audioData } = data;
|
||
|
|
|
||
|
|
// Pick a random user
|
||
|
|
const user = users[Math.floor(Math.random() * users.length)];
|
||
|
|
if (!user) {
|
||
|
|
console.error('No user available');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const headers = {
|
||
|
|
'Authorization': `Bearer ${user.accessToken}`,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Test 1: Stream health check
|
||
|
|
const healthStart = new Date();
|
||
|
|
const healthRes = http.get(`${STREAM_ORIGIN}/health`, {
|
||
|
|
headers,
|
||
|
|
tags: { name: 'stream_health' },
|
||
|
|
});
|
||
|
|
const healthEnd = new Date();
|
||
|
|
|
||
|
|
streamHealthDuration.add(healthEnd - healthStart);
|
||
|
|
|
||
|
|
check(healthRes, {
|
||
|
|
'stream health status is 200': (r) => r.status === 200,
|
||
|
|
'stream health response time < 100ms': (r) => r.timings.duration < 100,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (healthRes.status !== 200) {
|
||
|
|
streamErrorRate.add(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
sleep(0.5);
|
||
|
|
|
||
|
|
// Test 2: Upload audio (10% of requests)
|
||
|
|
if (Math.random() < 0.1) {
|
||
|
|
const formData = {
|
||
|
|
audio: http.file(audioData, 'test-audio.wav', 'audio/wav'),
|
||
|
|
};
|
||
|
|
|
||
|
|
const uploadStart = new Date();
|
||
|
|
const uploadRes = http.post(`${STREAM_ORIGIN}/upload`, formData, {
|
||
|
|
headers: {
|
||
|
|
...headers,
|
||
|
|
},
|
||
|
|
tags: { name: 'stream_upload' },
|
||
|
|
});
|
||
|
|
const uploadEnd = new Date();
|
||
|
|
|
||
|
|
streamUploadDuration.add(uploadEnd - uploadStart);
|
||
|
|
|
||
|
|
check(uploadRes, {
|
||
|
|
'upload status is 201': (r) => r.status === 201,
|
||
|
|
'upload has stream_id': (r) => {
|
||
|
|
try {
|
||
|
|
const body = JSON.parse(r.body);
|
||
|
|
return body.stream_id !== undefined;
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
if (uploadRes.status !== 201) {
|
||
|
|
streamErrorRate.add(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
sleep(0.5);
|
||
|
|
|
||
|
|
// Test 3: Stream playback endpoint
|
||
|
|
const playStart = new Date();
|
||
|
|
const playRes = http.get(`${STREAM_ORIGIN}/play/demo`, {
|
||
|
|
headers,
|
||
|
|
tags: { name: 'stream_play' },
|
||
|
|
responseType: 'none', // Don't store response body to save memory
|
||
|
|
});
|
||
|
|
const playEnd = new Date();
|
||
|
|
|
||
|
|
streamPlayDuration.add(playEnd - playStart);
|
||
|
|
|
||
|
|
check(playRes, {
|
||
|
|
'play status is 200': (r) => r.status === 200,
|
||
|
|
'play has audio content-type': (r) => {
|
||
|
|
const contentType = r.headers['Content-Type'] || r.headers['content-type'];
|
||
|
|
return contentType && contentType.includes('audio');
|
||
|
|
},
|
||
|
|
'play response time < 300ms': (r) => r.timings.duration < 300,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (playRes.status === 200) {
|
||
|
|
// Record bytes streamed
|
||
|
|
const contentLength = parseInt(playRes.headers['Content-Length'] || '0');
|
||
|
|
bytesStreamed.add(contentLength);
|
||
|
|
} else {
|
||
|
|
streamErrorRate.add(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
sleep(1);
|
||
|
|
|
||
|
|
// Test 4: Get stream metadata (50% of requests)
|
||
|
|
if (Math.random() < 0.5) {
|
||
|
|
const metadataRes = http.get(`${STREAM_ORIGIN}/metadata/demo`, {
|
||
|
|
headers,
|
||
|
|
tags: { name: 'stream_metadata' },
|
||
|
|
});
|
||
|
|
|
||
|
|
check(metadataRes, {
|
||
|
|
'metadata status is 200': (r) => r.status === 200,
|
||
|
|
'metadata has duration': (r) => {
|
||
|
|
try {
|
||
|
|
const body = JSON.parse(r.body);
|
||
|
|
return body.duration !== undefined;
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
sleep(1);
|
||
|
|
|
||
|
|
// Test 5: List available streams (20% of requests)
|
||
|
|
if (Math.random() < 0.2) {
|
||
|
|
const listRes = http.get(`${STREAM_ORIGIN}/streams`, {
|
||
|
|
headers,
|
||
|
|
tags: { name: 'stream_list' },
|
||
|
|
});
|
||
|
|
|
||
|
|
check(listRes, {
|
||
|
|
'list status is 200': (r) => r.status === 200,
|
||
|
|
'list returns array': (r) => {
|
||
|
|
try {
|
||
|
|
const body = JSON.parse(r.body);
|
||
|
|
return Array.isArray(body.streams || body);
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
sleep(2); // Think time between iterations
|
||
|
|
}
|
||
|
|
|
||
|
|
// Teardown
|
||
|
|
export function teardown(data) {
|
||
|
|
console.log('Streaming load test completed');
|
||
|
|
console.log(`Total users created: ${data.users.length}`);
|
||
|
|
}
|