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'); // The help pages are HTML too: the same structural walk validates them. const helpPages = ['help.html', 'help-ru.html'].map(f => ({ name: f, text: fs.readFileSync(path.join(__dirname, '..', f), 'utf8'), })); // The Russian dictionary must cover every user-visible key: the t('...') // literals in the script and every data-l10n / data-l10n-title attribute. // A missing key silently falls back to English - that is how the suite // views' Run/Move/Copy buttons stayed English until v3.22.5. test('every t() key and data-l10n attribute has a Russian entry', () => { const dictMatch = html.match(/const LANG_RU = \{([\s\S]*?)\n\};/); assert.ok(dictMatch, 'LANG_RU not found'); const norm = k => k .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16))) .replace(/\\'/g, "'") .replace(/"/g, '"').replace(/&/g, '&').replace(/'/g, "'"); const keys = new Set(); for (const m of dictMatch[1].matchAll(/^\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"):/gm)) { keys.add(norm(m[1] !== undefined ? m[1] : m[2])); } const missing = []; for (const m of html.matchAll(/\bt\(\s*'((?:[^'\\]|\\.)*)'\s*\)/g)) { if (!keys.has(norm(m[1]))) missing.push('t(): ' + m[1]); } for (const m of html.matchAll(/data-l10n(?:-title)?="([^"]+)"/g)) { if (!keys.has(norm(m[1]))) missing.push('data-l10n: ' + m[1]); } assert.deepStrictEqual(missing, [], 'missing Russian entries: ' + missing.join(' | ')); }); // Tags that never take an end tag. const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']); // End tags HTML allows to be omitted: a close tag may legally skip these. const OPTIONAL_END = new Set(['p', 'li', 'dt', 'dd', 'option', 'optgroup', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'rt', 'rp', 'colgroup', 'caption']); // Start tags that implicitly close the listed open element (the subset used here). const IMPLIED_END = { li: ['li'], dt: ['dt', 'dd'], dd: ['dt', 'dd'], p: ['p'], option: ['option'], optgroup: ['optgroup'], tr: ['tr'], td: ['td', 'th'], th: ['td', 'th'], thead: ['thead', 'tbody', 'tfoot'], tbody: ['thead', 'tbody', 'tfoot'], tfoot: ['thead', 'tbody', 'tfoot'], }; // The markup without comments and embedded script/style bodies: only this // participates in tag nesting. JavaScript template strings may hold // unbalanced
fragments, which is exactly how a missing
stayed // invisible to the old whole-file count (the Simulator panels were nested // from v3.5.11 to v3.6.0 while the total stayed balanced). function htmlOnly(src) { return src.replace(//g, '') .replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(/]*>[\s\S]*?<\/style>/gi, ''); } // Walk the tag stream and report cross-nesting, stray end tags and unclosed // elements. Run on every HTML page for every change. HTML optional end tags // are honoured so a legitimately omitted

/ is not an error. function htmlStructureProblems(src) { const markup = htmlOnly(src); const problems = []; const stack = []; const re = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?(\/?)>/g; let m; while ((m = re.exec(markup))) { const closing = m[1] === '/'; const tag = m[2].toLowerCase(); const selfClosing = m[3] === '/'; if (VOID_TAGS.has(tag) || selfClosing) continue; if (!closing) { for (const t of (IMPLIED_END[tag] || [])) { while (stack.length && stack[stack.length - 1].tag === t) stack.pop(); } stack.push({ tag, at: m.index }); continue; } if (!stack.length) { problems.push(`stray at offset ${m.index}`); continue; } if (stack[stack.length - 1].tag === tag) { stack.pop(); continue; } let i = stack.length - 1; while (i >= 0 && stack[i].tag !== tag) i--; if (i < 0) { problems.push(`stray at offset ${m.index}`); continue; } const between = stack.slice(i + 1); if (between.every(e => OPTIONAL_END.has(e.tag))) stack.length = i; else { problems.push(`cross-nesting: at offset ${m.index} closes over ` + between.map(e => `<${e.tag}>`).join(' ')); } } for (const e of stack) { if (!OPTIONAL_END.has(e.tag)) problems.push(`unclosed <${e.tag}> at offset ${e.at}`); } return problems; } // Depth of a
in the parsed div stream: sibling panels share // one depth, a nested panel sits one level deeper. function divDepthOf(src, id) { const markup = htmlOnly(src); const re = /<(\/?)div\b[^>]*>/g; let depth = 0, m; while ((m = re.exec(markup))) { if (m[1] === '/') { depth -= 1; continue; } depth += 1; if (m[0].includes('id="' + id + '"')) return depth; } return -1; } test('HTML
tags are balanced in the markup (scripts excluded)', () => { const markup = htmlOnly(html); const opens = (markup.match(//g) || []).length; assert.strictEqual(opens, closes, `Unbalanced divs: ${opens} opens vs ${closes} closes`); }); test('HTML tag nesting is well-formed on every page', () => { for (const page of [{ name: 'index.html', text: html }, ...helpPages]) { assert.deepStrictEqual(htmlStructureProblems(page.text), [], page.name + ' has structure problems'); } }); test('the Simulator panels are siblings of the tab container, never nested', () => { const ids = ['phone-sub-phone', 'phone-sub-tr', 'phone-sub-bip', 'phone-sub-esim', 'phone-sub-test']; const depths = ids.map(id => { const d = divDepthOf(html, id); assert.ok(d > 0, 'panel missing: ' + id); return d; }); assert.strictEqual(new Set(depths).size, 1, 'Simulator panels are nested at different depths: ' + ids.map((id, i) => id + '=' + depths[i]).join(', ')); }); test('top-level tabs match the rearranged views', () => { const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]); assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'scp81', 'cards', 'profiler', 'pysim', 'phone']); assert.match(html, /data-tab="c-apdu">Remote APDU { assert.match(html, /data-l10n="SCP81">SCP81 { const fieldsets = [...html.matchAll(//g)].map(m => m[0]); const pinAdm = fieldsets.find(f => /data-l10n="PIN\/ADM"/.test(f)); const tar = fieldsets.find(f => /data-l10n="TAR"/.test(f)); const scp80 = fieldsets.find(f => /data-l10n="SCP80 \(GSM 03\.48, ETSI TS 102 225\)"/.test(f)); const scp81 = fieldsets.find(f => /data-l10n="SCP81 \(HTTP OTA\)"/.test(f)); assert.ok(pinAdm, 'PIN/ADM fieldset not found'); assert.ok(tar, 'TAR fieldset not found'); assert.ok(scp80, 'SCP80 card fieldset not found'); assert.ok(scp81, 'SCP81 card fieldset not found'); // the optional card codes (plain text; PIN/PUK are 4-8 decimal digits) for (const id of ['cards-pin1', 'cards-puk1', 'cards-pin2', 'cards-puk2', 'cards-adm']) { assert.ok(pinAdm.includes('id="' + id + '"'), id + ' not in the PIN/ADM fieldset'); } // the TAR table: uniform rows rendered into the body, Add TAR below assert.match(tar, //); assert.match(tar, /onclick="cardsTarAdd\(\)"/); assert.match(tar, />TAR<\/th>/); assert.match(tar, />MSL<\/th>/); assert.match(tar, />Description<\/th>/); // the keyset editor and the PSK pair stay as implemented assert.match(scp80, /id="cards-keysets"/); assert.match(scp80, /onclick="cardsKeysetAdd\(\)"/); // ... and mirror the TAR table: a fixed-layout table with headers assert.match(scp80, //); assert.match(scp80, //); for (const h of ['KIc', 'KID', 'KIc key', 'KID key', 'Counter (hex)']) { assert.ok(scp80.includes('>' + h + ''), h); } assert.match(scp81, /id="cards-psk-id"/); assert.match(scp81, /id="cards-psk-key"/); // the PSK explanation lives inside the SCP81 group, not outside it assert.match(scp81, /data-l10n="SCP81 HTTP OTA: the listener picks the key/); // the preset-wide SPI1/SPI2 inputs are gone: each TAR row carries its MSL assert.ok(!html.includes('id="cards-spi1"'), 'the preset-wide SPI1 input is gone'); assert.ok(!html.includes('id="cards-spi2"'), 'the preset-wide SPI2 input is gone'); // the name/ICCID fields are in the editor but not inside the fieldsets assert.ok(!fieldsets.some(f => f.includes('id="cards-iccid"'))); assert.ok(!fieldsets.some(f => f.includes('id="cards-name"'))); }); test('the ADM input allows the full 4-16 hex digit key', () => { // the server's _verify_adm takes 4-16 hex digits (padded to 8 CHV bytes); // the input must not cap it at the PIN length const m = /]*>/.exec(html); assert.ok(m, 'the ADM input is missing'); const limit = /maxlength="(\d+)"/.exec(m[0]); assert.ok(!limit || parseInt(limit[1], 10) >= 16, 'the ADM input must allow 4-16 hex digits (server _verify_adm)'); }); test('the PIN/ADM and TAR fieldsets share one responsive row', () => { const start = html.indexOf('
'); assert.ok(start >= 0, 'the PIN/ADM + TAR row is missing'); const row = html.slice(start, html.indexOf('id="cards-keysets"')); // both fieldsets live in the row: side by side from lg, stacked below assert.match(row, /data-l10n="PIN\/ADM"/); assert.match(row, /data-l10n="TAR"/); assert.match(row, /class="[^"]*rounded p-3 lg:w-56 shrink-0"/); assert.match(row, /class="[^"]*rounded p-3 flex-1 min-w-0"/); // the codes are one-line rows: label left, input right assert.match(row, /class="flex items-center gap-2"[\s\S]*?for="cards-pin1"[\s\S]*?id="cards-pin1"/); assert.match(row, /for="cards-pin1"[^>]*class="w-10 shrink-0/); // the TAR table is fixed-layout so the columns hug their content and the // description absorbs the rest (no even spreading) assert.match(row, /
/); assert.match(row, /
<\/th>/); // the hint is the short form assert.match(row, /data-l10n="All codes are optional"/); assert.ok(!row.includes('not consumed yet'), 'the long codes hint is gone'); }); test('the Add TAR / Add keyset buttons sit below their lists, green', () => { assert.match(html, /onclick="cardsTarAdd\(\)" class="[^"]*bg-emerald-600[^"]*"/); assert.match(html, /onclick="cardsKeysetAdd\(\)" class="[^"]*bg-emerald-600[^"]*"/); assert.ok(html.indexOf('id="cards-tars"') < html.indexOf('onclick="cardsTarAdd()"'), 'Add TAR must follow the TAR list'); assert.ok(html.indexOf('id="cards-keysets"') < html.indexOf('onclick="cardsKeysetAdd()"'), 'Add keyset must follow the keyset list'); assert.ok(html.indexOf('onclick="cardsKeysetAdd()"') > html.indexOf('data-l10n="OTA keysets'), 'the keyset label stays above the list'); }); test('the Cards tab splits the preset list and the editor into views', () => { assert.match(html, /id="cards-list-view"/); assert.match(html, /id="cards-editor-view" class="hidden"/); assert.match(html, /id="cards-new-btn"[^>]*onclick="cardsNew\(\)"/); assert.match(html, /id="cards-editor-title"/); const listView = html.slice(html.indexOf('id="cards-list-view"'), html.indexOf('id="cards-editor-view"')); const editorView = html.slice(html.indexOf('id="cards-editor-view"'), html.indexOf('id="event-send-modal"')); // the list view holds the table, the Add preset button and export/import assert.match(listView, /id="cards-tbody"/); assert.match(listView, /id="cards-file"/); assert.match(listView, /id="cards-io"/); assert.match(listView, /cardsExportFile\(\)/); assert.ok(!listView.includes('id="cards-keysets"'), 'the editor must not be in the list view'); // the editor view holds the fieldsets and the save/cancel buttons assert.match(editorView, /id="cards-keysets"/); assert.match(editorView, /id="cards-pin1"/); assert.match(editorView, /id="cards-tars"/); assert.match(editorView, /id="cards-add-btn"[^>]*onclick="cardsAdd\(\)"/); assert.match(editorView, /onclick="cardsCancelEdit\(\)"/); assert.ok(!editorView.includes('id="cards-tbody"'), 'the list table must not be in the editor'); }); test('the ADM field sits in the PIN/ADM fieldset of the editor', () => { const fieldsets = [...html.matchAll(//g)].map(m => m[0]); const pinAdm = fieldsets.find(f => /data-l10n="PIN\/ADM"/.test(f)); assert.ok(pinAdm, 'PIN/ADM fieldset not found'); assert.match(pinAdm, /id="cards-adm"[^>]*placeholder="ADM"/); // the From card button sits in the editor header, above the code fields assert.ok(html.indexOf('id="cards-iccid-from-card"') < html.indexOf('id="cards-pin1"'), 'the From card button must precede the code fields'); }); test('card-dependent profiler buttons show the ICCID and need a readable one', () => { assert.match(html, /id="profiler-from-card-btn" data-needs="card-iccid"/); assert.match(html, /id="snapshot-new-btn" data-needs="card-iccid"/); assert.match(html, /data-needs="card-iccid" onclick="profilerCheck/); // both static buttons carry the dynamic ICCID span assert.match(html, /id="profiler-from-card-btn"[\s\S]*?<\/span><\/button>/); assert.match(html, /id="snapshot-new-btn"[\s\S]*?<\/span><\/button>/); }); test('network state monitor panel lives next to the network simulation', () => { assert.match(html, /data-l10n="Network state"/); assert.match(html, /id="netstate-badge"/); assert.match(html, /id="netstate-refresh-btn" data-needs="card"/); assert.match(html, /id="netstate-body"/); assert.match(html, /id="netsim-write-fplmn"/); // the monitor is rendered from the cached server state (no file polling) assert.ok(html.includes("netStateFetch()")); }); test('labelled containers use fieldsets with embedded legends', () => { const legendCls = 'HTTP OTA listener Script results (R-APDUs) \s*\s*/); }); test('PLI qualifier tables cover all standard qualifiers', () => { // ESN (07), MEID (0B) and Supported RATs (1A) must at least be named, in // both the TR Config dictionary and the proactive-log short labels. const pli = /const PLI_QUALIFIERS = \[([\s\S]*?)\];/.exec(html); assert.ok(pli, 'PLI_QUALIFIERS not found'); for (const code of ['07', '0B', '1A']) { assert.ok(pli[1].includes("{code:'" + code + "'"), 'PLI_QUALIFIERS missing ' + code); } const block = /const CMD_QUALIFIER_SHORT = \{([\s\S]*?)\n\};/.exec(html); assert.ok(block, 'CMD_QUALIFIER_SHORT not found'); const short = /'26': \{([^}]*)\}/.exec(block[1]); assert.ok(short, "CMD_QUALIFIER_SHORT['26'] not found"); for (const key of ['0x07', '0x0B', '0x1A']) { assert.ok(short[1].includes(key + ':'), 'CMD_QUALIFIER_SHORT 26 missing ' + key); } }); test('profile rows have a Clone action', () => { assert.match(html, /onclick="profilerClone\(' \+ i \+ '\)"/); assert.match(html, /t\('Clone'\)/); }); test('the C-APDU and R-APDU parsers are sub-pills of the Parser pill', () => { // The Parser pill is a top-level Remote APDU subtab... assert.match(html, /data-sub="parser" onclick="cApduSwitchSubtab\('parser'\)"/); // ...and both parser panels live inside its container, one after the other. const container = html.indexOf('id="c-apdu-sub-parser"'); const parse = html.indexOf('id="c-apdu-sub-parse"'); const response = html.indexOf('id="c-apdu-sub-response"'); assert.ok(container > 0, 'Parser container missing'); assert.ok(parse > container, 'C-APDU panel must be inside the Parser container'); assert.ok(response > parse, 'R-APDU panel must follow the C-APDU panel'); // Their sub-pill buttons exist and switch through parserSwitchSubtab. assert.match(html, /id="parser-btn-parse" onclick="parserSwitchSubtab\('parse'\)"/); assert.match(html, /id="parser-btn-response" onclick="parserSwitchSubtab\('response'\)"/); }); test('the document and its inline script are complete', () => { // A truncated index.html (missing ) makes the whole // inline script fail to parse in the browser: every onclick handler then // reports "function is not defined" while the Node tests still pass, // because they extract functions from the text without ever running the // document's script. assert.ok(html.trimEnd().endsWith(''), 'index.html must end with '); assert.strictEqual((html.match(//g) || []).length, 'every