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:
@@ -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 })));
|
||||
});
|
||||
|
||||
|
||||
@@ -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'
|
||||
? '<span class="pill mobile">📱 mobile</span>'
|
||||
: '<span class="pill desktop">🖥 desktop</span>';
|
||||
const cacheBadge = t.cache_mode === 'bust'
|
||||
? '<span class="pill bust">cache-bust</span>'
|
||||
: t.cache_mode === 'no-cache'
|
||||
@@ -253,7 +271,7 @@ async function loadHistory() {
|
||||
<span class="badge ${t.status}">${t.status}</span>
|
||||
<span class="url">${escHtml(t.url)}</span>
|
||||
<span class="meta">${t.http_method} · ${t.vus} VUs · ${dur}</span>
|
||||
${cacheBadge}${gzipBadge}
|
||||
${deviceBadge}${cacheBadge}${gzipBadge}
|
||||
</div>
|
||||
<div class="meta">${date}</div>
|
||||
${summaryLine ? `<div class="history-summary">${escHtml(summaryLine)}</div>` : ''}
|
||||
|
||||
@@ -67,6 +67,17 @@
|
||||
</select>
|
||||
</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">🖥 Desktop</label>
|
||||
<input type="radio" name="device" id="device-mobile" value="mobile" />
|
||||
<label for="device-mobile" class="device-btn">📱 Mobile</label>
|
||||
</div>
|
||||
<div class="hint" id="ua-hint">Chrome 124 on Windows</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Encoding</label>
|
||||
<div class="toggle-row">
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user