diff --git a/backend/server.js b/backend/server.js index 543bb54..7d738cd 100644 --- a/backend/server.js +++ b/backend/server.js @@ -27,6 +27,7 @@ db.exec(` headers TEXT, cache_mode TEXT NOT NULL DEFAULT 'normal', gzip INTEGER NOT NULL DEFAULT 1, + device TEXT NOT NULL DEFAULT 'desktop', status TEXT NOT NULL DEFAULT 'pending', created_at INTEGER NOT NULL, finished_at INTEGER, @@ -38,19 +39,28 @@ db.exec(` // Non-destructive migration for existing DBs try { db.exec(`ALTER TABLE tests ADD COLUMN cache_mode TEXT NOT NULL DEFAULT 'normal'`); } catch {} try { db.exec(`ALTER TABLE tests ADD COLUMN gzip INTEGER NOT NULL DEFAULT 1`); } catch {} +try { db.exec(`ALTER TABLE tests ADD COLUMN device TEXT NOT NULL DEFAULT 'desktop'`); } catch {} // Stream active test outputs (testId -> {process, clients}) const activeTests = {}; +const USER_AGENTS = { + desktop: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + mobile: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', +}; + // Generate a k6 script from test params function generateScript(params) { - const { url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip } = params; + const { url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip, device } = params; // Build the merged headers object const userHeaders = (() => { try { return JSON.parse(headers || '{}'); } catch { return {}; } })(); + // User-Agent + userHeaders['User-Agent'] = USER_AGENTS[device] || USER_AGENTS.desktop; + // Cache control headers if (cacheMode === 'no-cache' || cacheMode === 'bust') { userHeaders['Cache-Control'] = 'no-cache, no-store'; @@ -136,6 +146,7 @@ app.post('/api/tests', (req, res) => { headers = '{}', cacheMode = 'normal', gzip = true, + device = 'desktop', } = req.body; if (!url || !url.startsWith('http')) { @@ -146,11 +157,11 @@ app.post('/api/tests', (req, res) => { const createdAt = Date.now(); db.prepare(` - INSERT INTO tests (id, url, vus, duration, rps_limit, http_method, request_body, headers, cache_mode, gzip, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?) - `).run(id, url, vus, duration, rpsLimit || null, httpMethod, requestBody, headers, cacheMode, gzip ? 1 : 0, createdAt); + INSERT INTO tests (id, url, vus, duration, rps_limit, http_method, request_body, headers, cache_mode, gzip, device, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?) + `).run(id, url, vus, duration, rpsLimit || null, httpMethod, requestBody, headers, cacheMode, gzip ? 1 : 0, device, createdAt); - const script = generateScript({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip }); + const script = generateScript({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip, device }); const tmpScript = path.join(os.tmpdir(), `k6-script-${id}.js`); fs.writeFileSync(tmpScript, script); @@ -234,7 +245,7 @@ app.get('/api/tests/:id/stream', (req, res) => { // GET /api/tests — list all tests app.get('/api/tests', (req, res) => { - const rows = db.prepare('SELECT id, url, vus, duration, rps_limit, http_method, cache_mode, gzip, status, created_at, finished_at, summary FROM tests ORDER BY created_at DESC LIMIT 50').all(); + const rows = db.prepare('SELECT id, url, vus, duration, rps_limit, http_method, cache_mode, gzip, device, status, created_at, finished_at, summary FROM tests ORDER BY created_at DESC LIMIT 50').all(); res.json(rows.map(r => ({ ...r, summary: r.summary ? JSON.parse(r.summary) : null }))); }); diff --git a/frontend/app.js b/frontend/app.js index 36e5593..f15d203 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -39,6 +39,20 @@ gzipChk.addEventListener('change', () => { : 'Sends Accept-Encoding: identity — forces uncompressed response'; }); +// Device toggle hint +const UA = { + desktop: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + mobile: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', +}; +const uaHint = document.getElementById('ua-hint'); +document.querySelectorAll('input[name="device"]').forEach(radio => { + radio.addEventListener('change', () => { + uaHint.textContent = radio.value === 'mobile' + ? 'iPhone Safari 17 (iOS 17.4)' + : 'Chrome 124 on Windows'; + }); +}); + // ---- Run Test ---- const form = document.getElementById('test-form'); const startBtn = document.getElementById('start-btn'); @@ -60,6 +74,7 @@ form.addEventListener('submit', async (e) => { const headers = document.getElementById('headers').value.trim() || '{}'; const cacheMode = document.getElementById('cacheMode').value; const gzip = document.getElementById('gzip').checked; + const device = document.querySelector('input[name="device"]:checked').value; // Validate headers JSON try { JSON.parse(headers); } catch { @@ -80,7 +95,7 @@ form.addEventListener('submit', async (e) => { res = await fetch('/api/tests', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip }), + body: JSON.stringify({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip, device }), }); } catch (err) { alert('Failed to start test: ' + err.message); @@ -238,6 +253,9 @@ async function loadHistory() { summaryLine = parts.join(' · '); } + const deviceBadge = t.device === 'mobile' + ? '📱 mobile' + : '🖥 desktop'; const cacheBadge = t.cache_mode === 'bust' ? 'cache-bust' : t.cache_mode === 'no-cache' @@ -253,7 +271,7 @@ async function loadHistory() { ${t.status} ${escHtml(t.url)} ${t.http_method} · ${t.vus} VUs · ${dur} - ${cacheBadge}${gzipBadge} + ${deviceBadge}${cacheBadge}${gzipBadge}
${date}
${summaryLine ? `
${escHtml(summaryLine)}
` : ''} diff --git a/frontend/index.html b/frontend/index.html index 09f3bc1..b99066c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -67,6 +67,17 @@ +
+ +
+ + + + +
+
Chrome 124 on Windows
+
+
diff --git a/frontend/style.css b/frontend/style.css index 2122052..f8f1440 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -152,6 +152,30 @@ textarea { font-family: var(--mono); resize: vertical; } .pill.bust { background: rgba(239,68,68,.12); color: #f87171; } .pill.gzip { background: rgba(124,58,237,.15); color: var(--accent2); } .pill.no-gzip { background: rgba(100,116,139,.12); color: var(--muted); } +.pill.desktop { background: rgba(56,189,248,.12); color: #38bdf8; } +.pill.mobile { background: rgba(251,146,60,.12); color: #fb923c; } + +/* DEVICE TOGGLE */ +.device-toggle { display: flex; gap: 0; margin-top: .15rem; } +.device-toggle input[type=radio] { display: none; } +.device-btn { + flex: 1; + text-align: center; + padding: .45rem .75rem; + font-size: .85rem; + border: 1px solid var(--border); + cursor: pointer; + transition: background .15s, color .15s; + color: var(--muted); + background: transparent; +} +.device-btn:first-of-type { border-radius: 6px 0 0 6px; } +.device-btn:last-of-type { border-radius: 0 6px 6px 0; border-left: none; } +.device-toggle input[type=radio]:checked + .device-btn { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} .btn-secondary:hover { background: rgba(124,58,237,.15); } /* RESULT */