Files
simple/frontend/tests/testsuites.test.js
T
catarrh 30a742618e v3.30.4 follow-up: the suite delete re-resolves the selection by id
The independent re-check of the v3.30.4 identity fixes found a
pre-existing gap in testSuiteDelete: it cleared the selection only when
the deleted suite WAS the selected one, so deleting a suite BEFORE the
selection left _testSuiteIdx pointing at whatever moved into that slot
(or out of range when the list shrank).

The delete now captures the selected suite's id just before the store
refresh and re-resolves the index after it, which also keeps a selection
the user made while the dialog was open (the modal no longer freezes the
page).

Test: a behavioural test drives the real function through the four cases
(the selected suite deleted; a suite before it deleted; the selection
moved during the dialog; nothing selected) - mutation-checked to fail
without the re-resolution.  946 frontend + 952 Python.
2026-10-18 00:35:45 +03:00

261 lines
12 KiB
JavaScript

// Test suite view/report tests (v3.22.0): the report renderers, the Markdown
// export and the markup wiring of the four views under the Test script pill.
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name, asyncFn) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = '';
for (const fn of ['testStatusBadge', 'testRunStepHtml', 'testReportMemberHtml',
'testReportMarkdown', 'testSuiteProgress', 'testRoleOptions', 'testFailOptions',
'testSuiteSave']) {
code += extractFunc(html, fn) + '\n';
}
code += 'var _testSuites = []; var _testScripts = []; var _testSuiteIdx = -1;'
+ ' var _testView = "suite"; var _testSuiteSaveTimer = null;'
+ ' var _testSuiteTarget = null; var _testScriptId = null; var _testScriptDraft = null;\n';
eval(code);
globalThis.t = s => s;
globalThis.esc = s => String(s);
function snapshot() {
return {
name: 'regression', status: 'error', preset: 'SE test1',
started: 1, finished: 2,
suite: {
id: 'a'.repeat(32), name: 'regression', require_adm: true,
start_reset: true, reset: {ok: true, iccid: '8901000000000000001', ms: 1234},
session_start: 1, session_end: 1, session_changed: false,
counters_before: {'2': '0000000010'}, counters_after: {'2': '0000000012'},
members: [
{script_id: 'b'.repeat(32), name: 'setup', role: 'setup',
on_fail: 'stop', status: 'ok',
steps: [{index: 0, type: 'action', label: 'STATUS', status: 'ok',
checks: [{label: 'SW', ok: true, expected: '9000', actual: '9000'}]}],
log: ['TEST-RUN [1/3 setup setup] step 1: STATUS 1/1 -> 9000'], log_truncated: false},
{script_id: 'c'.repeat(32), name: 'alfa-dsa', role: 'member',
on_fail: 'stop', status: 'error',
note: 'step 2 failed',
steps: [{index: 0, type: 'action', label: 'SCP80', status: 'ok', checks: []},
{index: 1, type: 'expect', label: 'REFRESH', status: 'error',
checks: [{label: 'Command', ok: false, expected: 'REFRESH',
actual: '(none)', level: 'error'}]}],
log: ['TEST-RUN [2/3 member alfa-dsa] step 1: SCP80 -> SW=9103'], log_truncated: true},
{script_id: 'd'.repeat(32), name: 'teardown', role: 'teardown',
on_fail: 'stop', status: 'skipped', steps: [], log: [], log_truncated: false},
],
summary: {ok: 1, warning: 0, error: 1, skipped: 1, stopped: false,
session_changed: false, wall_ms: 12345,
counters_before: {'2': '0000000010'}, counters_after: {'2': '0000000012'},
setup: 'ok', teardown: 'skipped'},
},
adm: {required: true, verified: true},
};
}
test('testReportMarkdown renders the members, steps, notes and logs', () => {
const md = testReportMarkdown(snapshot());
assert.match(md, /# Test suite report: regression/);
assert.match(md, /- status: error/);
assert.match(md, /- Preset: SE test1/);
assert.match(md, /- started with a card reset \(ICCID 8901000000000000001\) \u00b7 1\.2 s/);
assert.match(md, /passed: 1, warnings: 0, failed: 1, skipped: 1/);
assert.match(md, /wall time: 12\.3 s/);
assert.match(md, /SCP80: 2 0000000010 -> 0000000012/);
assert.match(md, /## 2\. alfa-dsa \(member, on_fail: stop\) - error/);
assert.match(md, /> step 2 failed/);
assert.match(md, /### Step 2: REFRESH - error/);
assert.match(md, /- ✗ Command: expected REFRESH, actual \(none\)/);
assert.match(md, /TEST-RUN \[2\/3 member alfa-dsa\] step 1: SCP80 -> SW=9103/);
assert.match(md, /## 3\. teardown \(teardown, on_fail: stop\) - skipped/);
});
test('testReportMarkdown tolerates an empty snapshot', () => {
assert.match(testReportMarkdown(null), /# Test suite report: /);
});
test('testReportMemberHtml carries the status, note and log details', () => {
const member = snapshot().suite.members[1];
const htmlOut = testReportMemberHtml(member, 1);
assert.match(htmlOut, /alfa-dsa/);
assert.match(htmlOut, /step 2 failed/);
assert.match(htmlOut, /Run log/);
assert.match(htmlOut, /truncated/);
assert.match(htmlOut, /Command: /);
const teardown = testReportMemberHtml(snapshot().suite.members[2], 2);
assert.match(teardown, /teardown/);
assert.match(teardown, /skipped/);
});
test('testSuiteProgress counts the finished members only', () => {
assert.strictEqual(testSuiteProgress(snapshot()), '3/3');
const snap = snapshot();
snap.suite.members[1].status = 'running';
snap.suite.members[2].status = 'pending';
assert.strictEqual(testSuiteProgress(snap), '1/3');
});
test('the role and on_fail selects mark the stored value', () => {
assert.match(testRoleOptions('setup'), /value="setup" selected/);
assert.match(testRoleOptions('member'), /value="member" selected/);
assert.doesNotMatch(testRoleOptions('member'), /value="setup" selected/);
assert.match(testFailOptions('continue'), /value="continue" selected/);
assert.match(testFailOptions('stop'), /value="stop" selected/);
});
test('the Test script pill hosts the four suite views and their wiring', () => {
for (const id of ['test-view-suites', 'test-view-suite', 'test-view-script', 'test-view-report']) {
assert.ok(html.includes('id="' + id + '"'), 'missing ' + id);
}
for (const fn of ['testSuiteNew', 'testSuiteOpen', 'testSuiteDelete', 'testSuiteExport',
'testSuiteRun', 'testSuiteAddScript', 'testSuiteDeleteScript', 'testSuiteMoveScript',
'testSuiteTargetGo', 'testOpenScript', 'testScriptBack', 'testReportExportJson',
'testReportExportMarkdown', 'testImportFile', 'testViewShow']) {
assert.ok(html.includes('function ' + fn) || html.includes('async function ' + fn),
'missing ' + fn);
}
assert.ok(/id="test-suite-name"[^>]*oninput="testSuiteNameChanged\(\)"/.test(html));
assert.ok(/id="test-suite-adm"[^>]*onchange="testSuiteAdmChanged\(\)"/.test(html));
assert.ok(/id="test-suite-reset"[^>]*onchange="testSuiteResetChanged\(\)"/.test(html));
assert.ok(/data-l10n-title="Reset and fully re-initialize the card/.test(html));
assert.ok(/id="test-script-adm"[^>]*onchange="testScriptAdmChanged\(\)"/.test(html));
assert.ok(/id="test-suite-run-btn"[^>]*data-needs="card"/.test(html));
assert.ok(/id="test-run-btn"[^>]*data-needs="card"/.test(html));
});
test('the suite run opens the report view and names the preset', () => {
const src = extractFunc(html, 'testSuiteRun', true);
assert.match(src, /'\/api\/test\/run'/);
assert.match(src, /suite_id/);
assert.match(src, /_testView = 'report'/);
assert.match(src, /testRunPreset\(usesScp80\)/);
assert.match(src, /needsAdm/);
});
test('a single script run sends its require_adm flag and keeps its id', () => {
const src = extractFunc(html, 'testRunStart', true);
assert.match(src, /require_adm: !!cur\.require_adm/);
assert.match(src, /body\.script_id = cur\.id/);
assert.match(src, /testRenderRun\(resp\)/);
});
test('the suite save adopts the stored suite (the server normalises the order)', async () => {
const calls = [];
const suite = {id: 'a'.repeat(32), name: 'edited', require_adm: false,
start_reset: true, scripts: []};
_testSuites = [suite];
_testSuiteIdx = 0;
globalThis.testCurrentSuite = () => suite;
globalThis.testSuites = () => _testSuites;
globalThis.testRenderSuiteScripts = () => {};
globalThis.ioStatus = () => {};
globalThis.pysimFetch = async (path, body) => {
calls.push([path, body]);
return {ok: true, suite: Object.assign({}, suite, {name: 'stored'})};
};
testSuiteSave(true);
await new Promise(r => setTimeout(r, 10));
assert.strictEqual(calls[0][0], '/api/test/suites/update');
assert.strictEqual(calls[0][1].suite.scripts.length, 0);
assert.strictEqual(calls[0][1].suite.start_reset, true);
assert.strictEqual(_testSuites[0].name, 'stored');
});
test('role rows cannot be moved and the i18n refresh redraws the test views', () => {
const rows = extractFunc(html, 'testRenderSuiteScripts');
assert.match(rows, /const movable = e\.role === 'member'/);
assert.match(rows, /!movable \|\| i === 0/);
assert.match(rows, /!movable \|\| i === entries\.length - 1/);
const del = extractFunc(html, 'testSuiteDelete', true);
assert.match(del, /_testSuiteSaveTimer/);
const i18n = extractFunc(html, 'refreshDynamicI18n');
assert.match(i18n, /isViewVisible\('phone-sub-test'\)\) testRender\(\)/);
});
test('deleting a suite re-resolves the selection by id, not by index', async () => {
// The delete shifts every index after it and the list may be reordered
// while the confirmation is open (the modal does not block the page).
const del = extractFunc(html, 'testSuiteDelete', true);
const make = state => new Function('state', `
var _testSuiteIdx = state.selIdx;
var _testSuiteSaveTimer = null;
var testSuites = () => state.suites;
var testCurrentSuite = () => state.suites[_testSuiteIdx];
var pysimConfirm = async () => {
if (state.moveTo !== undefined && state.moveTo !== null) _testSuiteIdx = state.moveTo;
return true;
};
var pysimFetch = async (path, body) => { state.deleted = body.id; return {}; };
var testStoresFetch = async () => {
state.suites = state.suites.filter(x => x.id !== state.deleted);
};
var testViewShow = v => { state.view = v; };
var ioStatus = (id, msg) => { state.status = msg; };
var t = x => x;
${del}
return { run: testSuiteDelete, idx: () => _testSuiteIdx };
`)(state);
// deleting the SELECTED suite clears the selection
let app = make({ suites: [{id: 'SA'}, {id: 'SB'}, {id: 'SC'}], selIdx: 2 });
await app.run(2);
assert.strictEqual(app.idx(), -1);
// deleting a suite BEFORE the selection keeps it selected (the index shifts)
app = make({ suites: [{id: 'SA'}, {id: 'SB'}, {id: 'SC'}], selIdx: 2 });
await app.run(0);
assert.strictEqual(app.idx(), 1);
// a selection made while the dialog was open wins
app = make({ suites: [{id: 'SA'}, {id: 'SB'}, {id: 'SC'}], selIdx: 0, moveTo: 1 });
await app.run(2);
assert.strictEqual(app.idx(), 1);
// nothing selected stays nothing selected
app = make({ suites: [{id: 'SA'}], selIdx: -1 });
await app.run(0);
assert.strictEqual(app.idx(), -1);
});
test('the report keeps an expanded run log open across the live polls', () => {
// A live suite run re-renders the member blocks on every poll; replacing
// innerHTML creates fresh <details>, so the open state is carried over
// by the stable member index.
const member = extractFunc(html, 'testReportMemberHtml');
assert.match(member, /data-member="/);
const report = extractFunc(html, 'testRenderReport');
assert.match(report, /details\[data-member\]/);
assert.match(report, /openLogs\[d\.dataset\.member\]/);
assert.match(report, /d\.open = true/);
});
test('the report header classes are generated in the prebuilt stylesheet', () => {
// The stale-style trap (v3.22.0 review): style.css is a prebuilt Tailwind
// output - a class used only in the PWA source has no rule until
// `npm run build` runs in frontend/. The report's member header is the
// regression case (a near-white bg + dark-theme text made titles vanish).
const css = fs.readFileSync(path.join(__dirname, '..', 'style.css'), 'utf8');
for (const sel of ['.bg-gray-50', '.dark\\:bg-slate-800\\/50',
'.dark\\:text-slate-200', '.bg-purple-100', '.dark\\:bg-purple-900\\/40',
'.min-w-\\[12rem\\]']) {
assert.ok(css.includes(sel), 'missing rule for ' + sel);
}
assert.match(extractFunc(html, 'testReportMemberHtml'),
/bg-gray-50 dark:bg-slate-800\/50/);
});