fix: close the eSIM panel so the Test script pill shows its panel (v3.6.1)
#phone-sub-esim was missing its closing </div> before #phone-sub-test, so the browser parsed the test panel (and everything after) as a child of the eSIM panel. Clicking Test script unhid the panel itself, but its hidden ancestor kept it invisible - the pill has never shown a panel in a real browser since it was added (v3.5.11). The eSIM LPA pill conversely showed the whole test editor below its own fields. The old whole-file div-count test stayed green because the inline script's template strings happen to contain one extra </div>; the real markup was 409 opens vs 408 closes. - add the missing </div>. - html.test.js: count divs on the markup only (scripts excluded) and add a structural walk (stray end tags, cross-nesting, unclosed elements, HTML optional end tags honoured) over index.html, help.html and help-ru.html, plus a Simulator-panel sibling-depth check. Run this for every HTML edit. - the walk immediately found the same class of bug in both help pages: six unclosed <section> chapters each; closed them. - AGENTS: the HTML structure test is now part of the working conventions. 602 frontend / 482 Python green; version 3.6.1; sw simple-v274.
This commit is contained in:
@@ -49,6 +49,7 @@
|
||||
</ul>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Вкладка Remote APDU</h2>
|
||||
<p class="mb-3">Построение командных APDU (C-APDU). Шесть подвкладок охватывают разные поколения карт, наборы команд и инструменты разбора: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>RAM/GP</strong>, <strong>Expanded Script</strong>, <strong>«Пуш/триггер»</strong> и <strong>«Разбор»</strong> — последняя содержит подвкладки <strong>«Разбор C-APDU»</strong> и <strong>«Разбор R-APDU»</strong>.</p>
|
||||
@@ -275,6 +276,7 @@
|
||||
</ul>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. Вкладка SCP80</h2>
|
||||
<p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — двумя переключателями: <strong>Secured Packet</strong> и <strong>RAM</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225. При подключении карты сервер читает её <strong>EF.ICCID</strong> (2FE2); если в предустановке указан тот же номер (цифрами или сырым hex из EF), она автоматически выбирается в обоих представлениях.</p>
|
||||
@@ -334,6 +336,7 @@
|
||||
<p class="text-sm mb-3">Удаление подтверждается через диалог браузера перед отправкой команды GP <code class="font-mono text-sm">DELETE</code> через SCP80. Обзор автоматически обновляется после успешного удаления.</p>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка «Карты»</h2>
|
||||
<p class="mb-2">Хранит предустановки карт локально в браузере (<code class="font-mono text-sm">localStorage</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, а слушатель SCP81 HTTP OTA — находить PSK-ключи. «Карты» — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и предустановка с тем же ICCID автоматически выбирается в обоих представлениях SCP80. Поля формы сгруппированы в два блока с рамкой: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, TAR-ы ISD/UICC RFM/ADF RFM, счётчик и ключи) и <strong>SCP81 (HTTP OTA)</strong> (пара PSK identity/ключ с пояснением о выборе ключа). Необязательное поле <strong>ADM</strong> находится в верхней строке и сохраняется для последующего использования файловым менеджером. В шапке отображаются серые маркеры <strong>SCP80</strong>/<strong>SCP81</strong> и значок ключа на бейдже ADM, когда в предустановке с ICCID подключённой карты эти поля заполнены.</p>
|
||||
@@ -439,6 +442,7 @@
|
||||
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="profiler" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Профайлер</h2>
|
||||
<p class="text-sm mb-2">Проверяет соответствие карты именованному <strong>профилю</strong> — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
@@ -622,6 +626,7 @@ pysim-simple-server --http-port 8080</pre>
|
||||
<p class="text-sm mb-3">Если PWA раздаётся с публичного HTTPS-хоста (например, <code class="font-mono text-sm">https://simple.example.com</code>), для доступа к локальному серверу карт нужны два условия: (1) сервер отвечает на preflight заголовком <code class="font-mono text-sm">Access-Control-Allow-Private-Network: true</code> (pysim-simple-server ≥ 1.6.1 делает это автоматически), и (2) браузеру должно быть разрешено обращаться к локальной сети — в Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → разрешить сайт (или подтвердить запрос). Без разрешения браузера запрос к <code class="font-mono text-sm">127.0.0.1</code> блокируется ещё до отправки preflight.</p>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">10. Совместимость версий</h2>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
@@ -635,6 +640,8 @@ pysim-simple-server --http-port 8080</pre>
|
||||
<p class="text-sm">PWA проверяет версию сервера при подключении через <code class="font-mono text-sm">GET /api/version</code> и сравнивает мажорную версию (например, PWA 3.x с сервером 3.x; сервер 2.x помечается как устаревший).</p>
|
||||
|
||||
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
</ul>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Remote APDU tab</h2>
|
||||
<p class="mb-3">Builds command APDUs (C-APDUs). Six sub-tabs cover different card generations, command sets and decoding tools: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>RAM/GP</strong>, <strong>Expanded Script</strong>, <strong>Push commands</strong>, and <strong>Parser</strong> — the last with the <strong>C-APDU Parser</strong> and <strong>R-APDU parser</strong> sub-pills.</p>
|
||||
@@ -274,6 +275,7 @@
|
||||
</ul>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. SCP80 tab</h2>
|
||||
<p class="mb-3">The <strong>SCP80</strong> top-level tab groups the SCP80-related views. It is switched by two pills: <strong>Secured Packet</strong> and <strong>RAM</strong>. Assembles SCP80 secured packets per ETSI TS 102 225. When a card is equipped the server reads its <strong>EF.ICCID</strong> (2FE2); if a card preset carries the same number (digits, or the raw EF hex), it is selected automatically in both views.</p>
|
||||
@@ -333,6 +335,7 @@
|
||||
<p class="text-sm mb-3">Delete confirms via a browser prompt before sending the GP <code class="font-mono text-sm">DELETE</code> command via SCP80. The explorer auto-refreshes after a successful deletion.</p>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Cards tab</h2>
|
||||
<p class="mb-2">Stores card presets locally in the browser (<code class="font-mono text-sm">localStorage</code>) so the Secured Packet view can auto-fill keys and parameters, and so the SCP81 HTTP OTA listener can look up PSK keys. Cards is a top-level tab. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in both SCP80 views. The form groups the fields into two bordered blocks: <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (KIc/KID, SPI1/SPI2, the ISD/UICC RFM/ADF RFM TARs, counter and keys) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The optional <strong>ADM</strong> field sits in the top row and is stored for later use by the file manager. The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p>
|
||||
@@ -438,6 +441,7 @@
|
||||
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="profiler" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Profiler</h2>
|
||||
<p class="text-sm mb-2">Verifies that a card matches a named <strong>profile</strong> — an ordered set of rules describing the expected file system and (optionally) file contents. Profiles are stored in <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
@@ -621,6 +625,7 @@ pysim-simple-server --http-port 8080</pre>
|
||||
<p class="text-sm mb-3">If the PWA is served from a public HTTPS host (e.g. <code class="font-mono text-sm">https://simple.example.com</code>), two things are required to reach a local card server: (1) the server must answer the preflight with <code class="font-mono text-sm">Access-Control-Allow-Private-Network: true</code> (pysim-simple-server ≥ 1.6.1 does this automatically), and (2) the browser must be allowed to access the local network — in Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site (or accept the permission prompt). Without the browser permission, the request to <code class="font-mono text-sm">127.0.0.1</code> is blocked before any preflight is sent.</p>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">10. Version compatibility</h2>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
@@ -634,6 +639,8 @@ pysim-simple-server --http-port 8080</pre>
|
||||
<p class="text-sm">The PWA checks the server version on connect via <code class="font-mono text-sm">GET /api/version</code> and compares the major version (e.g. 3.x PWA with a 3.x server; a 2.x server is flagged as outdated).</p>
|
||||
|
||||
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+3
-2
@@ -1245,7 +1245,8 @@
|
||||
<div id="esim-notifications" class="text-xs"></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div id="phone-sub-test" class="hidden">
|
||||
</div>
|
||||
<div id="phone-sub-test" class="hidden">
|
||||
<div id="test-run-banner" class="hidden mb-3 px-3 py-2 rounded border border-amber-400 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-700 dark:text-amber-300"></div>
|
||||
<div class="flex flex-wrap items-end gap-2 mb-3">
|
||||
<div>
|
||||
@@ -1664,7 +1665,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.6.0';
|
||||
const SIMPLE_VERSION = '3.6.1';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== Tab switching =====
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v273';
|
||||
const CACHE = 'simple-v274';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
+107
-3
@@ -4,13 +4,117 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
const opens = (html.match(/<div\b/g) || []).length;
|
||||
const closes = (html.match(/<\/div>/g) || []).length;
|
||||
// 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'),
|
||||
}));
|
||||
|
||||
test('HTML <div> tags are balanced', () => {
|
||||
// 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 <div> fragments, which is exactly how a missing </div> 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(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style\b[^>]*>[\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 </p>/</li> 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 </${tag}> 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 </${tag}> 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: </${tag}> 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 <div id="..."> 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 <div> tags are balanced in the markup (scripts excluded)', () => {
|
||||
const markup = htmlOnly(html);
|
||||
const opens = (markup.match(/<div\b/g) || []).length;
|
||||
const closes = (markup.match(/<\/div>/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']);
|
||||
|
||||
Reference in New Issue
Block a user