Files
simple/frontend/tests/stk_menu.test.js
T
catarrh bd5dc69c4e fix: drop stale STK dialogs, finish pending on TP re-send, mute client-gone (v3.6.35)
The live log showed the "stuck" state: after the menu watchdog (60 s)
answered a pending command, the PWA kept the panel; the next click got
{'error': 'no pending command'}, 400 and that reply was fed to the dialog
handler as if it were a card answer.  A page reload then cancelled in-flight
polls, flooding the log with BrokenPipeError tracebacks.

- stkMenuRespond detects the already-answered reply (stkNoPendingError) and
  closes the panel (stkMenuPanelReset) instead of treating it as an answer;
  the 5 s status poll drops the panel whenever the server reports
  pending: false, even when the Phone tab is hidden.
- /api/terminal-profile (POST) and /api/rescue finish a pending command
  with a cancel TR first, so re-sending the TP can never shadow an
  unanswered FETCH.
- _send_json and _serve_static swallow BrokenPipe/ConnectionReset/
  ConnectionAborted with one "CLIENT GONE" line (a reload cancels polls);
  the handlers' state changes still stand.

Tests: stkNoPendingError + the already-answered respond path; version
3.6.35; sw simple-v308; stylesheet rebuilt.
2026-09-29 00:37:56 +03:00

135 lines
5.1 KiB
JavaScript

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('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 (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
let code = extractFunc(html, 'stkMenuRespond', true) + '\n';
code += extractFunc(html, 'stkNoPendingError') + '\n';
code += extractFunc(html, 'stkMenuPanelReset') + '\n';
code += extractFunc(html, 'stkMenuNaiSuffix') + '\n';
code += extractFunc(html, 'stkMenuItemsHtml') + '\n';
code += 'globalThis.esc = s => s;\n';
eval(code);
function setup(response) {
const calls = { handled: null, rendered: 0 };
globalThis.pysimFetch = async () => response;
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
globalThis.stkCheckMenu = () => {};
const btns = { classList: { add: () => {} } };
const back = { style: {} };
globalThis.document = {
getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back
: { innerHTML: '', classList: { add: () => {} } }),
};
return calls;
}
test('the STK menu item suffix shows the item next action (8.24)', () => {
assert.strictEqual(stkMenuNaiSuffix({ id: 1, text: 'Menu', nai: 0x25, nai_name: 'SET UP MENU' }),
'\u25b8 SET UP MENU');
// no NAI (or a reserved one, which the server drops) -> no suffix
assert.strictEqual(stkMenuNaiSuffix({ id: 2, text: 'Info' }), '');
assert.strictEqual(stkMenuNaiSuffix(null), '');
});
test('the overlay item list shows the next action and the row handler', () => {
const list = stkMenuItemsHtml([
{ id: 1, text: 'Menu', nai: 0x25, nai_name: 'SET UP MENU' },
{ id: 2, text: 'Info' },
], 'stkSubItemClick');
assert.match(list, /onclick="stkSubItemClick\(1\)"/);
assert.match(list, /onclick="stkSubItemClick\(2\)"/);
assert.ok(list.includes('Menu'));
assert.ok(list.includes('\u25b8 SET UP MENU'));
// the second item has no indicator -> exactly one suffix in the list
assert.strictEqual((list.match(/\u25b8/g) || []).length, 1);
assert.strictEqual(stkMenuItemsHtml(null, 'stkMenuItemClick'), '<div class="space-y-1"></div>');
});
test('both overlay lists render through the shared row helper', () => {
// the cached top menu and the pending SELECT ITEM items must use the same
// renderer, otherwise the latter silently loses the NAI suffix
assert.match(html, /stkMenuItemsHtml\(data\.items, 'stkMenuItemClick'\)/);
assert.match(html, /stkMenuItemsHtml\(data\.items, 'stkSubItemClick'\)/);
});
test('back with a fetched SELECT ITEM continues the card dialogue', async () => {
const data = { type: 'select_item', items: [{ id: 1, text: 'Info' }] };
const calls = setup(data);
await stkMenuRespond('back');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('back with a fetched DISPLAY TEXT shows it', async () => {
const data = { type: 'display_text', text: 'hello' };
const calls = setup(data);
await stkMenuRespond('back');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('timeout with a fetched SELECT ITEM continues the card dialogue', async () => {
const data = { type: 'select_item', items: [] };
const calls = setup(data);
await stkMenuRespond('timeout');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('back answered with SW 9000 falls back to the cached top menu', async () => {
const calls = setup({ type: 'done', sw: '9000' });
await stkMenuRespond('back');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 1);
});
test('cancel falls back to the cached top menu', async () => {
const calls = setup({ sw: '9000' });
await stkMenuRespond('cancel');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 1);
});
test('ok navigates with the server response', async () => {
const data = { type: 'select_item', items: [{ id: 1, text: 'x' }] };
const calls = setup(data);
await stkMenuRespond('ok');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('stkNoPendingError detects the already-answered reply', () => {
assert.strictEqual(stkNoPendingError({ error: 'no pending command' }), true);
assert.strictEqual(stkNoPendingError({ sw: '9000', type: 'done' }), false);
assert.strictEqual(stkNoPendingError({ error: 'other' }), false);
assert.strictEqual(stkNoPendingError(null), false);
});
test('an already-answered respond does not reach the dialog handler', async () => {
const calls = setup({ error: 'no pending command' });
await stkMenuRespond('ok');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 0);
});