v3.37.0: load testing for test scripts and suites
POST /api/test/run takes an optional iterations count (the PWA's Iterations field): the run repeats, successful iterations are discarded (steps/log cleared, only load.done and the timing grow), the first error stops the loop and keeps that iteration's report, a stop ends it after the current iteration, and a card session change between iterations is an error. The SCP80 counter state is shared across iterations, so packets continue from what the previous run consumed. The status gains a load block (iteration/done/failed_iteration/ elapsed_ms/avg_ms; disabled for a normal run). The suite runner was split into a per-iteration body (_test_suite_execute_once) + the single-run finaliser, and two load workers own the loops. Review fixes: a raised runner error finalises as error (no stuck running state), a suite-internal session change is an error, a stopped member ends the suite loop, the final status keeps a warning from any iteration, avg_ms covers the attempted iterations, and each suite iteration's counters_before is read fresh. 1041 Python + 1069 frontend; simple-v475.
This commit is contained in:
1 parent
851ff65af6
commit
2181ef6bbf
13 files changed
+937
-64
No files matched your search
+106
-24
@@ -1412,7 +1412,7 @@
|
||||
</div>
|
||||
<label class="flex items-center gap-1 text-sm text-gray-700 dark:text-slate-300"><input id="test-suite-adm" type="checkbox" onchange="testSuiteAdmChanged()"> <span data-l10n="ADM required">ADM required</span></label>
|
||||
<label class="flex items-center gap-1 text-sm text-gray-700 dark:text-slate-300" data-l10n-title="Reset and fully re-initialize the card (ICCID, network state, TERMINAL PROFILE, drain) before the suite starts. The preset must still match the card's ICCID after the reset; the ADM is verified again for the new card session. The card's initial proactive commands are consumed by the initialization, so a script cannot catch the first command after the reset (they stay in the proactive log). A reset later in the run still stops the suite."><input id="test-suite-reset" type="checkbox" onchange="testSuiteResetChanged()"> <span data-l10n="Start with card reset">Start with card reset</span></label>
|
||||
<button id="test-suite-run-btn" data-needs="card" onclick="testSuiteRunCurrent()" class="px-4 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Run suite">Run suite</button>
|
||||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400"><span data-l10n="Iterations">Iterations</span><input id="test-suite-iterations" type="number" min="1" max="1000000" value="1" oninput="testIterationsChanged(this)" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></label> <button id="test-suite-run-btn" data-needs="card" onclick="testSuiteRunCurrent()" class="px-4 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Run suite">Run suite</button>
|
||||
<button onclick="testSuiteExportCurrent()" class="px-3 py-1.5 text-sm rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Export">Export</button>
|
||||
<button onclick="testSuiteDeleteCurrent()" class="px-3 py-1.5 text-sm rounded bg-gray-200 dark:bg-slate-700 text-red-600 dark:text-red-400 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Delete">Delete</button>
|
||||
</div>
|
||||
@@ -1434,7 +1434,7 @@
|
||||
<input id="test-name" oninput="testNameChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||||
</div>
|
||||
<label class="flex items-center gap-1 text-sm text-gray-700 dark:text-slate-300"><input id="test-script-adm" type="checkbox" onchange="testScriptAdmChanged()"> <span data-l10n="ADM required">ADM required</span></label>
|
||||
<button id="test-run-btn" data-needs="card" onclick="testRunStart()" class="px-4 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Start">Start</button>
|
||||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400"><span data-l10n="Iterations">Iterations</span><input id="test-iterations" type="number" min="1" max="1000000" value="1" oninput="testIterationsChanged(this)" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></label> <button id="test-run-btn" data-needs="card" onclick="testRunStart()" class="px-4 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Start">Start</button>
|
||||
<button id="test-stop-btn" onclick="testStop()" class="hidden px-4 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-700" data-l10n="Stop">Stop</button>
|
||||
<button onclick="testClearReport()" class="px-3 py-1.5 text-sm rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Clear report">Clear report</button>
|
||||
</div>
|
||||
@@ -1959,7 +1959,7 @@
|
||||
// ===== Version =====
|
||||
// Single source of truth for the PWA version: shown in the header and used
|
||||
// by the server version check in pysimConnect().
|
||||
const SIMPLE_VERSION = '3.36.0';
|
||||
const SIMPLE_VERSION = '3.37.0';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== view history (the browser Back/Forward buttons) =====
|
||||
@@ -7531,6 +7531,7 @@ function testViewShow(view) {
|
||||
}
|
||||
|
||||
function testRender() {
|
||||
if (!_testIterationsReady) { _testIterationsReady = true; testIterationsInit(); }
|
||||
const views = {suites: 'test-view-suites', suite: 'test-view-suite',
|
||||
script: 'test-view-script', report: 'test-view-report'};
|
||||
Object.keys(views).forEach(v => {
|
||||
@@ -7542,8 +7543,12 @@ function testRender() {
|
||||
if (banner) {
|
||||
if (running) {
|
||||
const snap = _testRunState || {};
|
||||
const what = snap.suite ? t('Test suite running:') : t('Test script running:');
|
||||
const at = snap.suite ? testSuiteProgress(snap) : ((snap.index + 1) + '/' + snap.total);
|
||||
const load = snap.load && snap.load.enabled ? snap.load : null;
|
||||
const isSuite = !!(snap.suite || (load && load.suite));
|
||||
const what = isSuite ? t('Test suite running:') : t('Test script running:');
|
||||
const at = load
|
||||
? (t('Iteration') + ' ' + (load.iteration || 0) + '/' + load.iterations)
|
||||
: (snap.suite ? testSuiteProgress(snap) : ((snap.index + 1) + '/' + snap.total));
|
||||
banner.textContent = '\u26a0 ' + what + ' ' + (snap.name || '') + ' (' + at + ')';
|
||||
banner.classList.remove('hidden');
|
||||
} else {
|
||||
@@ -8920,15 +8925,24 @@ function testRenderScriptRun() {
|
||||
stepsEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const load = snap.load && snap.load.enabled ? snap.load : null;
|
||||
let head = esc(snap.name || '');
|
||||
if (snap.status) head += ' · ' + testStatusBadge(snap.status);
|
||||
else if (snap.running) head += ' · ' + esc(t('running'));
|
||||
if (load) {
|
||||
head += ' · ' + esc(t('Iteration')) + ' ' + (load.iteration || 0)
|
||||
+ ' / ' + load.iterations;
|
||||
if (load.done) head += ' · ' + esc(t('done')) + ': ' + load.done;
|
||||
}
|
||||
if (snap.preset) head += ' · ' + esc(t('Preset')) + ': ' + esc(snap.preset);
|
||||
if (snap.scp80_counter) head += ' · counter ' + esc(snap.scp80_counter);
|
||||
if (snap.error) head += ' · ' + esc(snap.error);
|
||||
statusEl.innerHTML = head;
|
||||
statusEl.className = 'text-xs mb-2 text-gray-600 dark:text-slate-300';
|
||||
stepsEl.innerHTML = (snap.steps || []).map(testRunStepHtml).join('');
|
||||
// a load test shows the counter while it runs; only the failure report is
|
||||
// rendered (successful iterations are not tracked)
|
||||
stepsEl.innerHTML = (load && snap.running)
|
||||
? '' : (snap.steps || []).map(testRunStepHtml).join('');
|
||||
}
|
||||
|
||||
// ── the suite report ──
|
||||
@@ -8940,16 +8954,28 @@ function testRenderReport() {
|
||||
if (!titleEl || !sumEl || !memEl) return;
|
||||
const snap = _testRunState;
|
||||
const s = snap && snap.suite;
|
||||
if (!s) {
|
||||
// a load test keeps no suite state on success: the run summary is enough
|
||||
const load = snap && snap.load && snap.load.enabled ? snap.load : null;
|
||||
if (!s && !load) {
|
||||
titleEl.textContent = t('No suite run yet.');
|
||||
sumEl.textContent = '';
|
||||
memEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
titleEl.innerHTML = esc(s.name || t('Suite')) + ' · '
|
||||
titleEl.innerHTML = esc((s && s.name) || (snap && snap.name) || t('Suite')) + ' · '
|
||||
+ testStatusBadge(snap.status || (snap.running ? 'running' : ''));
|
||||
const parts = [];
|
||||
const su = s.summary;
|
||||
if (load) {
|
||||
parts.push(t('Iteration') + ' ' + (load.iteration || 0) + ' / ' + load.iterations);
|
||||
if (load.done) parts.push(t('done') + ': ' + load.done);
|
||||
if (load.elapsed_ms !== null && load.elapsed_ms !== undefined) {
|
||||
parts.push((load.elapsed_ms / 1000).toFixed(1) + ' s');
|
||||
}
|
||||
if (load.avg_ms !== null && load.avg_ms !== undefined) {
|
||||
parts.push(t('avg') + ' ' + load.avg_ms + ' ms');
|
||||
}
|
||||
}
|
||||
const su = s && s.summary;
|
||||
if (su) {
|
||||
parts.push(t('passed') + ': ' + su.ok, t('warnings') + ': ' + su.warning,
|
||||
t('failed') + ': ' + su.error, t('skipped') + ': ' + su.skipped);
|
||||
@@ -8957,19 +8983,19 @@ function testRenderReport() {
|
||||
if (su.wall_ms !== undefined && su.wall_ms !== null) parts.push((su.wall_ms / 1000).toFixed(1) + ' s');
|
||||
}
|
||||
if (snap.preset) parts.push(t('Preset') + ': ' + snap.preset);
|
||||
if (s.start_reset) {
|
||||
if (s && s.start_reset) {
|
||||
const r = s.reset || {};
|
||||
let line = t('started with a card reset');
|
||||
if (r.iccid) line += ' (ICCID ' + r.iccid + ')';
|
||||
if (r.ms !== undefined && r.ms !== null) line += ' \u00b7 ' + (r.ms / 1000).toFixed(1) + ' s';
|
||||
parts.push(line);
|
||||
}
|
||||
if (s.counters_before && Object.keys(s.counters_before).length) {
|
||||
if (s && s.counters_before && Object.keys(s.counters_before).length) {
|
||||
const after = s.counters_after || {};
|
||||
parts.push('SCP80: ' + Object.keys(s.counters_before).map(k =>
|
||||
k + ' ' + (s.counters_before[k] || '-') + ' \u2192 ' + (after[k] || '-')).join(', '));
|
||||
}
|
||||
if (s.session_changed) parts.push('\u26a0 ' + t('card session changed mid-suite'));
|
||||
if (s && s.session_changed) parts.push('\u26a0 ' + t('card session changed mid-suite'));
|
||||
if (snap.adm && snap.adm.required) parts.push('ADM: ' + (snap.adm.verified ? t('verified') : t('not verified')));
|
||||
if (snap.error) parts.push(esc(snap.error));
|
||||
sumEl.innerHTML = esc(parts.join(' · '));
|
||||
@@ -8977,14 +9003,20 @@ function testRenderReport() {
|
||||
// creates fresh <details>, so an expanded Run log would collapse each
|
||||
// time. Carry the open state across the re-render (the member list is
|
||||
// fixed at run start, so the index is a stable key).
|
||||
const showMembers = !!s && (!load || snap.status === 'error' || snap.status === 'stopped');
|
||||
const openLogs = {};
|
||||
memEl.querySelectorAll('details[data-member]').forEach(d => {
|
||||
openLogs[d.dataset.member] = d.open;
|
||||
});
|
||||
memEl.innerHTML = (s.members || []).map(testReportMemberHtml).join('');
|
||||
memEl.querySelectorAll('details[data-member]').forEach(d => {
|
||||
if (openLogs[d.dataset.member]) d.open = true;
|
||||
});
|
||||
if (showMembers) {
|
||||
memEl.querySelectorAll('details[data-member]').forEach(d => {
|
||||
openLogs[d.dataset.member] = d.open;
|
||||
});
|
||||
}
|
||||
memEl.innerHTML = showMembers
|
||||
? (s.members || []).map(testReportMemberHtml).join('') : '';
|
||||
if (showMembers) {
|
||||
memEl.querySelectorAll('details[data-member]').forEach(d => {
|
||||
if (openLogs[d.dataset.member]) d.open = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function testReportMemberHtml(m, i) {
|
||||
@@ -9018,7 +9050,8 @@ function testDownloadText(name, text, mime) {
|
||||
}
|
||||
|
||||
function testReportExportJson() {
|
||||
if (!_testRunState || !_testRunState.suite) {
|
||||
if (!_testRunState || (!_testRunState.suite
|
||||
&& !(_testRunState.load && _testRunState.load.enabled))) {
|
||||
ioStatus('test-io-status', t('No suite run yet.'));
|
||||
return;
|
||||
}
|
||||
@@ -9026,7 +9059,8 @@ function testReportExportJson() {
|
||||
}
|
||||
|
||||
function testReportExportMarkdown() {
|
||||
if (!_testRunState || !_testRunState.suite) {
|
||||
if (!_testRunState || (!_testRunState.suite
|
||||
&& !(_testRunState.load && _testRunState.load.enabled))) {
|
||||
ioStatus('test-io-status', t('No suite run yet.'));
|
||||
return;
|
||||
}
|
||||
@@ -9132,6 +9166,7 @@ async function testRunStart() {
|
||||
try {
|
||||
const body = {script: {name: cur.name || '', steps: cur.steps,
|
||||
require_adm: !!cur.require_adm}};
|
||||
body.iterations = testIterationsValue('test-iterations');
|
||||
if (cur.id) body.script_id = cur.id;
|
||||
if (presetId) body.preset_id = presetId;
|
||||
_testRunScriptRef = cur;
|
||||
@@ -9165,6 +9200,7 @@ async function testSuiteRun(i) {
|
||||
}
|
||||
try {
|
||||
const body = {suite_id: s.id};
|
||||
body.iterations = testIterationsValue('test-suite-iterations');
|
||||
if (presetId) body.preset_id = presetId;
|
||||
const resp = await pysimFetch('/api/test/run', body);
|
||||
if (resp.error) { ioStatus('test-io-status', resp.error); return; }
|
||||
@@ -9179,6 +9215,46 @@ async function testSuiteRun(i) {
|
||||
|
||||
function testSuiteRunCurrent() { testSuiteRun(_testSuiteIdx); }
|
||||
|
||||
// ===== load testing (v3.37.0) =====
|
||||
// The iteration count is a run parameter, not part of the stored script or
|
||||
// suite: > 1 repeats the run, the successful iterations are discarded and the
|
||||
// first failing iteration's report is kept.
|
||||
const TEST_ITERATIONS_MAX = 1000000;
|
||||
let _testIterationsReady = false;
|
||||
|
||||
function testIterationsInit() {
|
||||
let n = 1;
|
||||
try { n = parseInt(localStorage.getItem('simple_test_iterations'), 10) || 1; }
|
||||
catch (e) { /* private mode */ }
|
||||
if (!(n >= 1)) n = 1;
|
||||
if (n > TEST_ITERATIONS_MAX) n = TEST_ITERATIONS_MAX;
|
||||
['test-iterations', 'test-suite-iterations'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = String(n);
|
||||
});
|
||||
}
|
||||
|
||||
function testIterationsChanged(input) {
|
||||
let n = parseInt((input && input.value) || '1', 10);
|
||||
if (!(n >= 1)) n = 1;
|
||||
if (n > TEST_ITERATIONS_MAX) n = TEST_ITERATIONS_MAX;
|
||||
if (input) input.value = String(n);
|
||||
try { localStorage.setItem('simple_test_iterations', String(n)); }
|
||||
catch (e) { /* ignore */ }
|
||||
['test-iterations', 'test-suite-iterations'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && el !== input) el.value = String(n);
|
||||
});
|
||||
}
|
||||
|
||||
function testIterationsValue(id) {
|
||||
const el = document.getElementById(id);
|
||||
let n = parseInt((el && el.value) || '1', 10);
|
||||
if (!(n >= 1)) n = 1;
|
||||
if (n > TEST_ITERATIONS_MAX) n = TEST_ITERATIONS_MAX;
|
||||
return n;
|
||||
}
|
||||
|
||||
|
||||
async function testStop() {
|
||||
try {
|
||||
@@ -9269,9 +9345,12 @@ async function testInit() {
|
||||
testRender();
|
||||
try {
|
||||
const st = await pysimFetch('/api/test/status');
|
||||
if (st && st.suite && st.suite.members) {
|
||||
// a suite run (or its finished report): open the report view
|
||||
const idx = testSuites().findIndex(s => s.id === st.suite.id);
|
||||
if (st && ((st.suite && st.suite.members)
|
||||
|| (st.load && st.load.enabled))) {
|
||||
// a suite run (or its finished report, a load run included):
|
||||
// open the report view
|
||||
const idx = st.suite
|
||||
? testSuites().findIndex(s => s.id === st.suite.id) : -1;
|
||||
if (idx >= 0) _testSuiteIdx = idx;
|
||||
_testRunState = st;
|
||||
if (_testView !== 'script') _testView = 'report';
|
||||
@@ -27212,6 +27291,9 @@ const LANG_RU = {
|
||||
'Move': 'Переместить',
|
||||
'Copy': 'Копировать',
|
||||
'No suite run yet.': 'Набор ещё не запускался.',
|
||||
'Iterations': 'Итерации',
|
||||
'Iteration': 'Итерация',
|
||||
'done': 'выполнено',
|
||||
'Warning': 'Предупреждение',
|
||||
'Text': 'Текст',
|
||||
'id': 'id',
|
||||
|
||||
Reference in new issue
Block a user