198 lines
9.2 KiB
JavaScript
198 lines
9.2 KiB
JavaScript
/**
|
|
* rate_test.js — اختبار ضغط بمعدل مستمر (رحلات/ساعة) مع percentiles
|
|
* ==================================================================
|
|
* يكمل load_test.js (الذي يبقى لفحص الدخان بدفعة واحدة). الفروق:
|
|
* 1. معدل وصول مستمر: --rate رحلة/ساعة طوال --duration دقيقة (مع ramp)
|
|
* 2. توليد JWT داخل Node (HS256 عبر crypto) — لا exec لعملية PHP لكل رحلة
|
|
* (كانت execSync تخنق مولّد الحمل نفسه فوق ~2 رحلة/ثانية)
|
|
* 3. قياس p50/p95/p99 لكل نقطة نداء + زمن العرض + الرحلة كاملة
|
|
*
|
|
* أمثلة:
|
|
* node rate_test.js --rate 10000 --duration 10 -b http://localhost:8080/backend -s http://localhost:2020
|
|
* node rate_test.js --rate 20000 --duration 5 --env ../backend/.env
|
|
*/
|
|
|
|
const io = require('socket.io-client');
|
|
const axios = require('axios');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const yargs = require('yargs/yargs');
|
|
const { hideBin } = require('yargs/helpers');
|
|
|
|
const argv = yargs(hideBin(process.argv))
|
|
.option('rate', { alias: 'r', type: 'number', default: 10000, description: 'رحلات في الساعة' })
|
|
.option('duration', { alias: 'd', type: 'number', default: 10, description: 'مدة الاختبار بالدقائق' })
|
|
.option('ramp', { type: 'number', default: 1, description: 'دقائق التصعيد التدريجي' })
|
|
.option('base-url', { alias: 'b', type: 'string', default: 'http://localhost:8080/backend' })
|
|
.option('socket-url', { alias: 's', type: 'string', default: 'http://localhost:2020' })
|
|
.option('env', { type: 'string', default: path.join(__dirname, '..', 'backend', '.env'), description: 'ملف .env لقراءة سر JWT' })
|
|
.option('jwt-secret', { type: 'string', description: 'تجاوز مباشر لسر JWT' })
|
|
.argv;
|
|
|
|
// ── سر JWT: نفس أولويات generate_mock_data.php ──
|
|
function loadSecret() {
|
|
if (argv.jwtSecret) return argv.jwtSecret;
|
|
const env = {};
|
|
try {
|
|
for (const line of fs.readFileSync(argv.env, 'utf8').split('\n')) {
|
|
const t = line.trim();
|
|
if (!t || t.startsWith('#') || !t.includes('=')) continue;
|
|
const i = t.indexOf('=');
|
|
env[t.slice(0, i).trim()] = t.slice(i + 1).trim().replace(/^["']|["']$/g, '');
|
|
}
|
|
} catch (e) { /* يسقط للفحص أدناه */ }
|
|
if (env.JWT_SECRET_KEY_PATH && fs.existsSync(env.JWT_SECRET_KEY_PATH))
|
|
return fs.readFileSync(env.JWT_SECRET_KEY_PATH, 'utf8').trim();
|
|
return env.JWT_SECRET_KEY || env.JWT_SECRET || '';
|
|
}
|
|
|
|
const b64u = (buf) => Buffer.from(buf).toString('base64url');
|
|
function makeJwt(secret, driverId) {
|
|
const header = b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
const body = b64u(JSON.stringify({ sub: String(driverId), role: 'driver', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 86400 }));
|
|
const sig = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
|
|
return `${header}.${body}.${sig}`;
|
|
}
|
|
|
|
// ── مقاييس ──
|
|
const lat = { socket_connect: [], add_ride: [], offer_delay: [], accept: [], start: [], finish: [], trip_e2e: [] };
|
|
const counters = { launched: 0, ok: 0, fail: 0, socketErr: 0, apiErr: 0, timeout: 0 };
|
|
let active = 0;
|
|
|
|
function pct(arr, p) {
|
|
if (!arr.length) return 0;
|
|
const s = [...arr].sort((a, b) => a - b);
|
|
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
|
}
|
|
const fmt = (a) => `p50=${pct(a, 50)}ms p95=${pct(a, 95)}ms p99=${pct(a, 99)}ms max=${Math.max(0, ...a)}ms (n=${a.length})`;
|
|
|
|
async function post(name, url, form) {
|
|
const t0 = Date.now();
|
|
try {
|
|
const res = await axios.post(url, new URLSearchParams(form).toString(), {
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000,
|
|
});
|
|
lat[name].push(Date.now() - t0);
|
|
return res;
|
|
} catch (e) {
|
|
counters.apiErr++;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function simulateTrip(i, secret) {
|
|
const driverId = 900000 + i, passengerId = 800000 + i;
|
|
const jwt = makeJwt(secret, driverId);
|
|
const t0 = Date.now();
|
|
active++;
|
|
counters.launched++;
|
|
|
|
return new Promise((resolve) => {
|
|
let done = false, addRideAt = 0;
|
|
const socket = io(argv.socketUrl, {
|
|
query: { driver_id: String(driverId), platform: 'android', jwt },
|
|
reconnection: false, timeout: 10000, forceNew: true,
|
|
});
|
|
const finish = (ok) => {
|
|
if (done) return;
|
|
done = true;
|
|
ok ? counters.ok++ : counters.fail++;
|
|
if (ok) lat.trip_e2e.push(Date.now() - t0);
|
|
active--;
|
|
try { socket.disconnect(); } catch (_) {}
|
|
resolve(ok);
|
|
};
|
|
|
|
socket.on('connect_error', () => { counters.socketErr++; finish(false); });
|
|
socket.on('error', () => { counters.socketErr++; finish(false); });
|
|
|
|
socket.on('connect', async () => {
|
|
lat.socket_connect.push(Date.now() - t0);
|
|
try {
|
|
addRideAt = Date.now();
|
|
await post('add_ride', `${argv.baseUrl}/ride/rides/add_ride.php`, {
|
|
passenger_id: String(passengerId),
|
|
start_location: '31.95,35.91', end_location: '31.96,35.92',
|
|
price: '5', price_token: 'dummy', distance: '2', carType: 'Economy',
|
|
passenger_name: 'Test Passenger', passenger_phone: '0790000000',
|
|
start_name: 'Start', end_name: 'End',
|
|
});
|
|
} catch (_) { finish(false); }
|
|
});
|
|
|
|
socket.on('market_new_ride', async (payload) => {
|
|
const rideId = payload.id || payload[0]?.id;
|
|
if (!rideId) return finish(false);
|
|
lat.offer_delay.push(Date.now() - addRideAt);
|
|
try {
|
|
await post('accept', `${argv.baseUrl}/ride/rides/acceptRide.php`, { id: String(rideId), driver_id: String(driverId), status: 'accepted' });
|
|
let steps = 0;
|
|
const iv = setInterval(async () => {
|
|
steps++;
|
|
socket.emit('update_location', { lat: 31.95 + steps * 0.001, lng: 35.91 + steps * 0.001, heading: 90, speed: 40, status: 'on' });
|
|
try {
|
|
if (steps === 3) await post('start', `${argv.baseUrl}/ride/rides/start_ride.php`, { id: String(rideId), driver_id: String(driverId), status: 'start' });
|
|
if (steps === 6) {
|
|
clearInterval(iv);
|
|
await post('finish', `${argv.baseUrl}/ride/rides/finish_ride_updates.php`, { id: String(rideId), driver_id: String(driverId), status: 'finish' });
|
|
finish(true);
|
|
}
|
|
} catch (_) { clearInterval(iv); finish(false); }
|
|
}, 1000);
|
|
} catch (_) { finish(false); }
|
|
});
|
|
|
|
setTimeout(() => { if (!done) { counters.timeout++; finish(false); } }, 45000);
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const secret = loadSecret();
|
|
if (!secret) {
|
|
console.error(`❌ لا يوجد سر JWT — مرّر --jwt-secret أو --env لملف فيه JWT_SECRET_KEY (جرّبت: ${argv.env})`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const perSec = argv.rate / 3600;
|
|
const totalMs = argv.duration * 60 * 1000;
|
|
const rampMs = Math.min(argv.ramp * 60 * 1000, totalMs);
|
|
console.log(`\n🚀 اختبار معدل مستمر: ${argv.rate} رحلة/ساعة (${perSec.toFixed(2)}/ث) لمدة ${argv.duration} دقيقة (تصعيد ${argv.ramp} د)`);
|
|
console.log(`📡 API: ${argv.baseUrl}\n🔌 Socket: ${argv.socketUrl}\n`);
|
|
|
|
const t0 = Date.now();
|
|
let i = 0;
|
|
const inflight = [];
|
|
|
|
const progress = setInterval(() => {
|
|
const el = ((Date.now() - t0) / 1000).toFixed(0);
|
|
console.log(`[${el}s] أُطلق=${counters.launched} نجح=${counters.ok} فشل=${counters.fail} جارٍ=${active} | add_ride p95=${pct(lat.add_ride, 95)}ms عرض p95=${pct(lat.offer_delay, 95)}ms`);
|
|
}, 10000);
|
|
|
|
// مجدول مصحح الانحراف: يحسب كم رحلة كان يجب أن تكون قد انطلقت ويطلق الفرق
|
|
while (Date.now() - t0 < totalMs) {
|
|
const el = Date.now() - t0;
|
|
const rampFactor = el < rampMs ? el / rampMs : 1;
|
|
const due = Math.floor((el / 1000) * perSec * rampFactor);
|
|
while (i < due) { i++; inflight.push(simulateTrip(i, secret)); }
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
|
|
console.log(`\n⏳ توقف الإطلاق (${counters.launched} رحلة) — انتظار الرحلات الجارية (${active})…`);
|
|
await Promise.allSettled(inflight);
|
|
clearInterval(progress);
|
|
|
|
const durMin = ((Date.now() - t0) / 60000).toFixed(1);
|
|
const achieved = Math.round((counters.ok / ((Date.now() - t0) / 3600000)));
|
|
console.log(`\n${'='.repeat(60)}\n📊 النتائج (${durMin} دقيقة)\n${'='.repeat(60)}`);
|
|
console.log(`أُطلق: ${counters.launched} | نجح: ${counters.ok} | فشل: ${counters.fail} (سوكيت=${counters.socketErr} api=${counters.apiErr} مهلة=${counters.timeout})`);
|
|
console.log(`المعدل المحقق فعلياً: ~${achieved} رحلة/ساعة\n`);
|
|
for (const [k, v] of Object.entries(lat)) console.log(` ${k.padEnd(15)} ${fmt(v)}`);
|
|
console.log(`${'='.repeat(60)}`);
|
|
const pass = counters.fail / Math.max(1, counters.launched) < 0.01 && pct(lat.add_ride, 95) < 500;
|
|
console.log(pass ? '✅ الحكم: تحت العتبة (فشل <1% و p95<500ms)' : '⚠️ الحكم: فوق العتبة — انظر أبطأ نقطة أعلاه وسجل slow.log في MySQL');
|
|
process.exit(0);
|
|
}
|
|
|
|
main();
|