feat: add gzip toggle and cache mode (normal / no-cache / bust)

- Cache-mode selector: Normal, No-cache headers, Cache-bust URL+headers
- Cache-bust appends random ?_cb= per iteration to bypass CDN/proxy
- gzip toggle: on = k6 default (Accept-Encoding: gzip/br), off = identity
- Both options stored in DB with non-destructive migration
- History items show cache-mode and gzip pills
- Schema migration handles existing DBs gracefully

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 20:03:36 +02:00
parent 0cd0f96bab
commit 6ef7564e87
4 changed files with 119 additions and 11 deletions

View File

@@ -25,6 +25,8 @@ db.exec(`
http_method TEXT NOT NULL DEFAULT 'GET', http_method TEXT NOT NULL DEFAULT 'GET',
request_body TEXT, request_body TEXT,
headers TEXT, headers TEXT,
cache_mode TEXT NOT NULL DEFAULT 'normal',
gzip INTEGER NOT NULL DEFAULT 1,
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,
@@ -33,21 +35,43 @@ 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 {}
// Stream active test outputs (testId -> {process, clients}) // Stream active test outputs (testId -> {process, clients})
const activeTests = {}; const activeTests = {};
// 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 } = params; const { url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip } = params;
const parsedHeaders = (() => { // Build the merged headers object
const userHeaders = (() => {
try { return JSON.parse(headers || '{}'); } catch { return {}; } try { return JSON.parse(headers || '{}'); } catch { return {}; }
})(); })();
const headersJs = Object.entries(parsedHeaders) // Cache control headers
if (cacheMode === 'no-cache' || cacheMode === 'bust') {
userHeaders['Cache-Control'] = 'no-cache, no-store';
userHeaders['Pragma'] = 'no-cache';
}
// Encoding header
if (gzip === false || gzip === 0) {
userHeaders['Accept-Encoding'] = 'identity';
}
// When gzip=true, omit the header entirely so k6 sends its default (gzip, deflate, br)
const headersJs = Object.entries(userHeaders)
.map(([k, v]) => ` '${k.replace(/'/g, "\\'")}': '${v.replace(/'/g, "\\'")}',`) .map(([k, v]) => ` '${k.replace(/'/g, "\\'")}': '${v.replace(/'/g, "\\'")}',`)
.join('\n'); .join('\n');
// For cache-bust mode, append a random query param per iteration
const targetUrl = cacheMode === 'bust'
? `\`${url}${url.includes('?') ? '&' : '?'}_cb=\${Date.now()}-\${Math.random().toString(36).slice(2)}\``
: `'${url}'`;
const bodyLine = (httpMethod !== 'GET' && httpMethod !== 'HEAD' && requestBody) const bodyLine = (httpMethod !== 'GET' && httpMethod !== 'HEAD' && requestBody)
? `const body = \`${requestBody.replace(/`/g, '\\`')}\`;` ? `const body = \`${requestBody.replace(/`/g, '\\`')}\`;`
: 'const body = null;'; : 'const body = null;';
@@ -84,7 +108,7 @@ ${headersJs}
timeout: '30s', timeout: '30s',
}; };
const res = http.${httpMethod.toLowerCase()}('${url}'${httpMethod !== 'GET' && httpMethod !== 'HEAD' ? ', body' : ''}, params); const res = http.${httpMethod.toLowerCase()}(${targetUrl}${httpMethod !== 'GET' && httpMethod !== 'HEAD' ? ', body' : ''}, params);
const ok = check(res, { const ok = check(res, {
'status is 2xx': (r) => r.status >= 200 && r.status < 300, 'status is 2xx': (r) => r.status >= 200 && r.status < 300,
@@ -110,6 +134,8 @@ app.post('/api/tests', (req, res) => {
httpMethod = 'GET', httpMethod = 'GET',
requestBody = '', requestBody = '',
headers = '{}', headers = '{}',
cacheMode = 'normal',
gzip = true,
} = req.body; } = req.body;
if (!url || !url.startsWith('http')) { if (!url || !url.startsWith('http')) {
@@ -120,11 +146,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, status, created_at) INSERT INTO tests (id, url, vus, duration, rps_limit, http_method, request_body, headers, cache_mode, gzip, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?)
`).run(id, url, vus, duration, rpsLimit || null, httpMethod, requestBody, headers, createdAt); `).run(id, url, vus, duration, rpsLimit || null, httpMethod, requestBody, headers, cacheMode, gzip ? 1 : 0, createdAt);
const script = generateScript({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers }); const script = generateScript({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip });
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);
@@ -208,7 +234,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, 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, 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

@@ -30,6 +30,15 @@ methodSel.addEventListener('change', () => {
bodyGroup.style.display = (m !== 'GET' && m !== 'HEAD') ? '' : 'none'; bodyGroup.style.display = (m !== 'GET' && m !== 'HEAD') ? '' : 'none';
}); });
// gzip toggle hint
const gzipChk = document.getElementById('gzip');
const gzipHint = document.getElementById('gzip-hint');
gzipChk.addEventListener('change', () => {
gzipHint.textContent = gzipChk.checked
? 'k6 default — server may compress response'
: 'Sends Accept-Encoding: identity — forces uncompressed response';
});
// ---- 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');
@@ -49,6 +58,8 @@ form.addEventListener('submit', async (e) => {
const httpMethod = document.getElementById('httpMethod').value; const httpMethod = document.getElementById('httpMethod').value;
const requestBody = document.getElementById('requestBody').value.trim(); const requestBody = document.getElementById('requestBody').value.trim();
const headers = document.getElementById('headers').value.trim() || '{}'; const headers = document.getElementById('headers').value.trim() || '{}';
const cacheMode = document.getElementById('cacheMode').value;
const gzip = document.getElementById('gzip').checked;
// Validate headers JSON // Validate headers JSON
try { JSON.parse(headers); } catch { try { JSON.parse(headers); } catch {
@@ -69,7 +80,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 }), body: JSON.stringify({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers, cacheMode, gzip }),
}); });
} catch (err) { } catch (err) {
alert('Failed to start test: ' + err.message); alert('Failed to start test: ' + err.message);
@@ -227,12 +238,22 @@ async function loadHistory() {
summaryLine = parts.join(' · '); summaryLine = parts.join(' · ');
} }
const cacheBadge = t.cache_mode === 'bust'
? '<span class="pill bust">cache-bust</span>'
: t.cache_mode === 'no-cache'
? '<span class="pill no-cache">no-cache</span>'
: '<span class="pill cached">cached</span>';
const gzipBadge = t.gzip
? '<span class="pill gzip">gzip</span>'
: '<span class="pill no-gzip">no-gzip</span>';
item.innerHTML = ` item.innerHTML = `
<div> <div>
<div class="row1"> <div class="row1">
<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}
</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

@@ -58,6 +58,27 @@
<input type="number" id="rpsLimit" min="0" value="0" /> <input type="number" id="rpsLimit" min="0" value="0" />
</div> </div>
<div class="form-group">
<label for="cacheMode">Cache Mode</label>
<select id="cacheMode">
<option value="normal">Normal (allow cache)</option>
<option value="no-cache">No-cache headers (revalidate)</option>
<option value="bust">Cache-bust URL + headers (full bypass)</option>
</select>
</div>
<div class="form-group">
<label>Encoding</label>
<div class="toggle-row">
<label class="toggle">
<input type="checkbox" id="gzip" checked />
<span class="toggle-track"></span>
<span class="toggle-label">Accept gzip / br</span>
</label>
</div>
<div class="hint" id="gzip-hint">k6 default — server may compress response</div>
</div>
<div class="form-group full" id="body-group" style="display:none"> <div class="form-group full" id="body-group" style="display:none">
<label for="requestBody">Request Body</label> <label for="requestBody">Request Body</label>
<textarea id="requestBody" rows="4" placeholder='{"key": "value"}'></textarea> <textarea id="requestBody" rows="4" placeholder='{"key": "value"}'></textarea>
@@ -65,7 +86,7 @@
<div class="form-group full"> <div class="form-group full">
<label for="headers"> <label for="headers">
Custom Headers <span class="hint">(JSON, optional)</span> Custom Headers <span class="hint">(JSON, optional — merged with above options)</span>
</label> </label>
<input type="text" id="headers" placeholder='{"Authorization": "Bearer token"}' /> <input type="text" id="headers" placeholder='{"Authorization": "Bearer token"}' />
</div> </div>

View File

@@ -112,6 +112,46 @@ textarea { font-family: var(--mono); resize: vertical; }
cursor: pointer; cursor: pointer;
transition: background .15s; transition: background .15s;
} }
/* TOGGLE SWITCH */
.toggle-row { display: flex; align-items: center; gap: .75rem; margin-top: .15rem; }
.toggle { display: flex; align-items: center; gap: .55rem; cursor: pointer; }
.toggle input { display: none; }
.toggle-track {
width: 38px; height: 20px;
background: var(--border);
border-radius: 999px;
position: relative;
transition: background .2s;
flex-shrink: 0;
}
.toggle-track::after {
content: '';
position: absolute;
width: 14px; height: 14px;
background: #fff;
border-radius: 50%;
top: 3px; left: 3px;
transition: transform .2s;
}
.toggle input:checked + .toggle-track { background: var(--accent); }
.toggle input:checked + .toggle-track::after { transform: translateX(18px); }
.toggle-label { font-size: .88rem; color: var(--text); }
/* PILLS */
.pill {
display: inline-block;
font-size: .72rem;
font-weight: 600;
padding: .15rem .5rem;
border-radius: 999px;
letter-spacing: .04em;
}
.pill.cached { background: rgba(34,197,94,.12); color: #4ade80; }
.pill.no-cache { background: rgba(245,158,11,.12); color: #fbbf24; }
.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); }
.btn-secondary:hover { background: rgba(124,58,237,.15); } .btn-secondary:hover { background: rgba(124,58,237,.15); }
/* RESULT */ /* RESULT */