feat: add desktop/mobile device toggle (User-Agent)

- Segmented Desktop / Mobile button in the form
- Desktop: Chrome 124 on Windows
- Mobile: iPhone Safari 17 (iOS 17.4)
- User-Agent injected into k6 script headers
- Stored in DB with migration for existing data
- History items show device pill alongside cache/gzip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 20:13:13 +02:00
parent 6ef7564e87
commit cf51a4620b
4 changed files with 72 additions and 8 deletions

View File

@@ -27,6 +27,7 @@ db.exec(`
headers TEXT, headers TEXT,
cache_mode TEXT NOT NULL DEFAULT 'normal', cache_mode TEXT NOT NULL DEFAULT 'normal',
gzip INTEGER NOT NULL DEFAULT 1, gzip INTEGER NOT NULL DEFAULT 1,
device TEXT NOT NULL DEFAULT 'desktop',
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
finished_at INTEGER, finished_at INTEGER,
@@ -38,19 +39,28 @@ db.exec(`
// Non-destructive migration for existing DBs // 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 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 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}) // Stream active test outputs (testId -> {process, clients})
const activeTests = {}; 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 // Generate a k6 script from test params
function generateScript(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 // Build the merged headers object
const userHeaders = (() => { const userHeaders = (() => {
try { return JSON.parse(headers || '{}'); } catch { return {}; } try { return JSON.parse(headers || '{}'); } catch { return {}; }
})(); })();
// User-Agent
userHeaders['User-Agent'] = USER_AGENTS[device] || USER_AGENTS.desktop;
// Cache control headers // Cache control headers
if (cacheMode === 'no-cache' || cacheMode === 'bust') { if (cacheMode === 'no-cache' || cacheMode === 'bust') {
userHeaders['Cache-Control'] = 'no-cache, no-store'; userHeaders['Cache-Control'] = 'no-cache, no-store';
@@ -136,6 +146,7 @@ app.post('/api/tests', (req, res) => {
headers = '{}', headers = '{}',
cacheMode = 'normal', cacheMode = 'normal',
gzip = true, gzip = true,
device = 'desktop',
} = req.body; } = req.body;
if (!url || !url.startsWith('http')) { if (!url || !url.startsWith('http')) {
@@ -146,11 +157,11 @@ app.post('/api/tests', (req, res) => {
const createdAt = Date.now(); const createdAt = Date.now();
db.prepare(` db.prepare(`
INSERT INTO tests (id, url, vus, duration, rps_limit, http_method, request_body, headers, cache_mode, gzip, status, created_at) INSERT INTO tests (id, url, vus, duration, rps_limit, http_method, request_body, headers, cache_mode, gzip, device, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?)
`).run(id, url, vus, duration, rpsLimit || null, httpMethod, requestBody, headers, cacheMode, gzip ? 1 : 0, createdAt); `).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`); const tmpScript = path.join(os.tmpdir(), `k6-script-${id}.js`);
fs.writeFileSync(tmpScript, script); fs.writeFileSync(tmpScript, script);
@@ -234,7 +245,7 @@ app.get('/api/tests/:id/stream', (req, res) => {
// GET /api/tests — list all tests // GET /api/tests — list all tests
app.get('/api/tests', (req, res) => { 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 }))); res.json(rows.map(r => ({ ...r, summary: r.summary ? JSON.parse(r.summary) : null })));
}); });

View File

@@ -39,6 +39,20 @@ gzipChk.addEventListener('change', () => {
: 'Sends Accept-Encoding: identity — forces uncompressed response'; : '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 ---- // ---- Run Test ----
const form = document.getElementById('test-form'); const form = document.getElementById('test-form');
const startBtn = document.getElementById('start-btn'); const startBtn = document.getElementById('start-btn');
@@ -60,6 +74,7 @@ form.addEventListener('submit', async (e) => {
const headers = document.getElementById('headers').value.trim() || '{}'; const headers = document.getElementById('headers').value.trim() || '{}';
const cacheMode = document.getElementById('cacheMode').value; const cacheMode = document.getElementById('cacheMode').value;
const gzip = document.getElementById('gzip').checked; const gzip = document.getElementById('gzip').checked;
const device = document.querySelector('input[name="device"]:checked').value;
// Validate headers JSON // Validate headers JSON
try { JSON.parse(headers); } catch { try { JSON.parse(headers); } catch {
@@ -80,7 +95,7 @@ form.addEventListener('submit', async (e) => {
res = await fetch('/api/tests', { res = await fetch('/api/tests', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { } catch (err) {
alert('Failed to start test: ' + err.message); alert('Failed to start test: ' + err.message);
@@ -238,6 +253,9 @@ async function loadHistory() {
summaryLine = parts.join(' · '); summaryLine = parts.join(' · ');
} }
const deviceBadge = t.device === 'mobile'
? '<span class="pill mobile">&#128241; mobile</span>'
: '<span class="pill desktop">&#x1F5A5; desktop</span>';
const cacheBadge = t.cache_mode === 'bust' const cacheBadge = t.cache_mode === 'bust'
? '<span class="pill bust">cache-bust</span>' ? '<span class="pill bust">cache-bust</span>'
: t.cache_mode === 'no-cache' : t.cache_mode === 'no-cache'
@@ -253,7 +271,7 @@ async function loadHistory() {
<span class="badge ${t.status}">${t.status}</span> <span class="badge ${t.status}">${t.status}</span>
<span class="url">${escHtml(t.url)}</span> <span class="url">${escHtml(t.url)}</span>
<span class="meta">${t.http_method} · ${t.vus} VUs · ${dur}</span> <span class="meta">${t.http_method} · ${t.vus} VUs · ${dur}</span>
${cacheBadge}${gzipBadge} ${deviceBadge}${cacheBadge}${gzipBadge}
</div> </div>
<div class="meta">${date}</div> <div class="meta">${date}</div>
${summaryLine ? `<div class="history-summary">${escHtml(summaryLine)}</div>` : ''} ${summaryLine ? `<div class="history-summary">${escHtml(summaryLine)}</div>` : ''}

View File

@@ -67,6 +67,17 @@
</select> </select>
</div> </div>
<div class="form-group">
<label for="device">Device</label>
<div class="device-toggle">
<input type="radio" name="device" id="device-desktop" value="desktop" checked />
<label for="device-desktop" class="device-btn">&#x1F5A5; Desktop</label>
<input type="radio" name="device" id="device-mobile" value="mobile" />
<label for="device-mobile" class="device-btn">&#128241; Mobile</label>
</div>
<div class="hint" id="ua-hint">Chrome 124 on Windows</div>
</div>
<div class="form-group"> <div class="form-group">
<label>Encoding</label> <label>Encoding</label>
<div class="toggle-row"> <div class="toggle-row">

View File

@@ -152,6 +152,30 @@ textarea { font-family: var(--mono); resize: vertical; }
.pill.bust { background: rgba(239,68,68,.12); color: #f87171; } .pill.bust { background: rgba(239,68,68,.12); color: #f87171; }
.pill.gzip { background: rgba(124,58,237,.15); color: var(--accent2); } .pill.gzip { background: rgba(124,58,237,.15); color: var(--accent2); }
.pill.no-gzip { background: rgba(100,116,139,.12); color: var(--muted); } .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); } .btn-secondary:hover { background: rgba(124,58,237,.15); }
/* RESULT */ /* RESULT */