Import COMPRION trace exports (Protocol Layer + Application Layer time base)
- new `simtrace_analyser/comprion.py`: parses the tab-separated ISO Protocol
Layer export (metadata block, ATR/PPS exchanges, APDUs with continuation
rows, delays in etu) and re-encodes every exchange into the wire-shaped
TPDUs the analyser stores — a case-4 exchange becomes the command, a
fabricated `61 <len>` status word and a GET RESPONSE (command's logical
channel preserved), so command/response pairing, FCP/file decoding and the
GET RESPONSE filters work unchanged; Cold reset / Power off become RST/VCC
line events, deliberately without the sigrok CLK extension (the export
carries no clock measurement)
- optional matching **ISO Application Layer** export as the exact time base:
its one `Time:` row per exchange (ms precision) replaces the delay estimate
(0-based elapsed, `started = Session Name + first Time`), guarded by a
session-identity check (`Session Name`, falling back to `Working
directory`; the two layers' export timestamps differ and are not compared)
and an exchange-count check — mismatched files are refused, never mixed
- protocol-only imports keep the Delay-based timeline: card/line activity
time (host pauses absent), the unit assuming 3.8 MHz (`CLK_HZ`); the
estimate is not fed into the CLK/RST line-event annotation
- `POST /api/sessions/import?format=comprion` (raw text body, or a JSON
`{text, times_text}` bundle) with stable error codes (app_layer /
not_comprion / unsupported_layout / no_data / times_unparsable /
session_mismatch / time_base_unverified / time_base_mismatch); a COMPRION
file dropped on the PCAP path answers with a pointer (comprion_export);
session mode `comprion`, window from the Session Name
- PWA: import dialog with two separate pickers (Protocol Layer required,
Application Layer optional) whose hint switches between "activity time"
and "exact timestamps"; errors stay in the dialog with both selections
until fixed or cancelled (Cancel/Esc, also disabled while importing);
EN/RU
- tests: tests/test_comprion.py (parser, wire encoding, time base + guards,
import endpoint) and frontend/tests/comprion.test.js (file checks, slot
validation, dialog state machine with a DOM stub, RU coverage)
- v2.2.0; MIN_SERVER_VERSION 2.2.0; SW cache simtrace-analyser-v61
This commit is contained in:
1 parent
a50a93a238
commit
28bc8f1f24
11 files changed
+2021
-29
No files matched your search
+284
-18
@@ -278,6 +278,18 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
|
||||
.modal-buttons button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.modal-buttons .confirm { background: var(--accent); color: #fff; }
|
||||
.modal-buttons .cancel { background: var(--bg-input); color: var(--text-faint); }
|
||||
.modal-buttons button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* COMPRION import dialog */
|
||||
.comprion-modal { max-width: 560px; }
|
||||
.comprion-slot { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.comprion-slot label { flex: 0 0 200px; font-size: 13px; color: var(--text-muted); }
|
||||
.comprion-slot button { padding: 6px 10px; border: none; border-radius: 4px; cursor: pointer; background: var(--bg-input); color: var(--text); flex: 0 0 auto; }
|
||||
.comprion-slot button:disabled { opacity: 0.5; cursor: default; }
|
||||
.comprion-slot .file-name { font-size: 12px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.comprion-hint { font-size: 12px; line-height: 1.45; margin: 10px 0 0; }
|
||||
.comprion-error { font-size: 12px; line-height: 1.45; color: var(--error); margin-top: 8px; }
|
||||
.comprion-error:empty { display: none; }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
@@ -342,6 +354,7 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
|
||||
<span class="sessions-stats" id="sessions-stats"></span>
|
||||
<span class="import-msg" id="import-msg"></span>
|
||||
<button id="import-pcap-btn" data-l10n="Import PCAP">Import PCAP</button>
|
||||
<button id="import-comprion-btn" data-l10n="Import COMPRION trace" data-l10n-title="Import the 'ISO Protocol Layer' export, optionally with the matching 'ISO Application Layer' export for exact timestamps.">Import COMPRION trace</button>
|
||||
<button id="new-capture-btn" data-l10n="New capture">New capture</button>
|
||||
<input type="file" id="import-pcap-file" accept=".pcap,.pcapng,.cap" style="display:none;">
|
||||
<div class="session-view-toggle">
|
||||
@@ -406,6 +419,32 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COMPRION trace import dialog -->
|
||||
<div class="modal-overlay" id="comprion-modal">
|
||||
<div class="modal comprion-modal">
|
||||
<h3 data-l10n="Import COMPRION trace">Import COMPRION trace</h3>
|
||||
<p data-l10n="Select the ISO Protocol Layer export and, if available, the matching ISO Application Layer export of the same session.">Select the ISO Protocol Layer export and, if available, the matching ISO Application Layer export of the same session.</p>
|
||||
<div class="comprion-slot">
|
||||
<label data-l10n="ISO Protocol Layer export (required)">ISO Protocol Layer export (required)</label>
|
||||
<button id="comprion-protocol-btn" data-l10n="Choose file…">Choose file…</button>
|
||||
<span class="file-name" id="comprion-protocol-name"></span>
|
||||
</div>
|
||||
<div class="comprion-slot">
|
||||
<label data-l10n="ISO Application Layer export (optional)">ISO Application Layer export (optional)</label>
|
||||
<button id="comprion-application-btn" data-l10n="Choose file…">Choose file…</button>
|
||||
<span class="file-name" id="comprion-application-name"></span>
|
||||
</div>
|
||||
<input type="file" id="comprion-protocol-file" accept=".txt,.tsv" style="display:none;">
|
||||
<input type="file" id="comprion-application-file" accept=".txt,.tsv" style="display:none;">
|
||||
<p class="comprion-hint" id="comprion-hint"></p>
|
||||
<div class="comprion-error" id="comprion-error"></div>
|
||||
<div class="modal-buttons">
|
||||
<button class="cancel" id="comprion-cancel" data-l10n="Cancel">Cancel</button>
|
||||
<button class="confirm" id="comprion-import" data-l10n="Import" disabled>Import</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ──────────────────── Service worker ────────────────────
|
||||
if ('serviceWorker' in navigator) {
|
||||
@@ -1112,16 +1151,28 @@ const API = {
|
||||
getLatest(after) { return this._fetch('GET', `/api/capture/latest?after=${after}`); },
|
||||
searchMessages(sessionId, query) { return this._fetch('GET', `/api/apdu/search?session_id=${sessionId}&q=${encodeURIComponent(query)}`); },
|
||||
filterMessages(sessionId, type) { return this._fetch('GET', `/api/apdu/filter?session_id=${sessionId}&type=${type}`); },
|
||||
async importPcap(name, bytes) {
|
||||
const res = await fetch(API_BASE() + '/api/sessions/import?name=' + encodeURIComponent(name), {
|
||||
async _importPost(params, body, contentType) {
|
||||
const res = await fetch(API_BASE() + '/api/sessions/import?' + params, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: bytes,
|
||||
headers: { 'Content-Type': contentType },
|
||||
body: body,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw Object.assign(new Error(data.error || 'Import failed'), { status: res.status });
|
||||
if (!res.ok) throw Object.assign(new Error(data.error || 'Import failed'),
|
||||
{ status: res.status, code: data.code });
|
||||
return data;
|
||||
},
|
||||
async importSession(name, bytes, format) {
|
||||
const params = 'name=' + encodeURIComponent(name) +
|
||||
(format ? '&format=' + encodeURIComponent(format) : '');
|
||||
return this._importPost(params, bytes, 'application/octet-stream');
|
||||
},
|
||||
async importComprionBundle(name, text, timesText) {
|
||||
const params = 'name=' + encodeURIComponent(name) + '&format=comprion';
|
||||
return this._importPost(params,
|
||||
JSON.stringify({ text: text, times_text: timesText }),
|
||||
'application/json');
|
||||
},
|
||||
};
|
||||
|
||||
// ──────────────────── UI Helpers ────────────────────
|
||||
@@ -2418,20 +2469,175 @@ async function loadSessions() {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function importPcapFile(file) {
|
||||
// Import failure codes -> user-facing strings (the server answers the same
|
||||
// text; the map is what gets translated).
|
||||
const IMPORT_ERROR_KEYS = {
|
||||
app_layer: "This is the 'ISO Application Layer' export — select the 'ISO Protocol Layer' file (it contains the raw APDU bytes).",
|
||||
not_comprion: "Not a COMPRION export — expected the 'ISO Protocol Layer' text file.",
|
||||
unsupported_layout: "Unsupported COMPRION export layout (expected the 'ISO Protocol Layer' table).",
|
||||
no_data: 'No APDU exchanges found in the COMPRION export.',
|
||||
times_unparsable: 'No usable Time entries found in the ISO Application Layer export.',
|
||||
session_mismatch: 'The two files belong to different sessions — select the ISO Application Layer export of the same session.',
|
||||
time_base_unverified: 'Cannot verify that the two files belong to the same session (no Session Name in either file).',
|
||||
time_base_mismatch: 'The ISO Application Layer file does not match this trace (different exchange count).',
|
||||
protocol_slot: "This is the ISO Protocol Layer export — select it in the first slot.",
|
||||
identical: 'The two files are identical.',
|
||||
comprion_export: "This looks like a COMPRION export — use 'Import COMPRION trace'.",
|
||||
};
|
||||
|
||||
// Cheap pre-upload check of a COMPRION export's layer title: 'app_layer' /
|
||||
// 'not_comprion' when the file is clearly the wrong one, '' to upload (the
|
||||
// server performs the full layout validation).
|
||||
function comprionFileCheck(text) {
|
||||
const heads = [];
|
||||
for (const line of text.split(/\r?\n/).slice(0, 12)) {
|
||||
const cells = line.split('\t').map(c => c.trim());
|
||||
heads.push(cells[0] || '');
|
||||
}
|
||||
if (heads.includes('ISO Application Layer')) return 'app_layer';
|
||||
if (heads.includes('ISO Protocol Layer')) return '';
|
||||
return 'not_comprion';
|
||||
}
|
||||
|
||||
function importErrorText(e) {
|
||||
const key = e && e.code && IMPORT_ERROR_KEYS[e.code];
|
||||
return t(key || (e && e.message) || 'Import failed');
|
||||
}
|
||||
|
||||
// Which slot accepts which export? *slot* is 'protocol' | 'application',
|
||||
// *kind* is a comprionFileCheck() result. Returns '' when acceptable, or the
|
||||
// error code to show.
|
||||
function comprionSlotCheck(slot, kind) {
|
||||
if (kind === 'not_comprion') return 'not_comprion';
|
||||
if (slot === 'protocol' && kind === 'app_layer') return 'app_layer';
|
||||
if (slot === 'application' && kind === '') return 'protocol_slot';
|
||||
return '';
|
||||
}
|
||||
|
||||
function importMsg(text, error) {
|
||||
const msg = el('import-msg');
|
||||
msg.classList.remove('error');
|
||||
msg.textContent = t('Importing…');
|
||||
msg.classList.toggle('error', !!error);
|
||||
msg.textContent = text;
|
||||
}
|
||||
|
||||
function importBaseName(file) {
|
||||
return file.name.replace(/\.[^.]+$/, '') || file.name;
|
||||
}
|
||||
|
||||
function importSuccessText(data) {
|
||||
let text = t('Imported \'{name}\' — {n} messages')
|
||||
.replace('{name}', data.name).replace('{n}', data.message_count);
|
||||
if (data.time_base === 'application-layer') {
|
||||
text += ' · ' + t('exact timestamps (Application Layer)');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function finishImport(run) {
|
||||
importMsg(t('Importing…'));
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '') || file.name;
|
||||
const data = await API.importPcap(baseName, buf);
|
||||
msg.textContent = t('Imported \'{name}\' — {n} messages')
|
||||
.replace('{name}', data.name).replace('{n}', data.message_count);
|
||||
const data = await run();
|
||||
importMsg(importSuccessText(data));
|
||||
loadSessions();
|
||||
} catch (e) {
|
||||
msg.classList.add('error');
|
||||
msg.textContent = t((e && e.message) || 'Import failed');
|
||||
importMsg(importErrorText(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function importPcapFile(file) {
|
||||
await finishImport(async () =>
|
||||
API.importSession(importBaseName(file), await file.arrayBuffer(), ''));
|
||||
}
|
||||
|
||||
// ──────────────────── COMPRION import dialog ────────────────────
|
||||
|
||||
const COMPRION_DLG = { protocol: null, application: null, busy: false, error: '' };
|
||||
|
||||
function comprionSameFile(a, b) {
|
||||
return !!a && !!b && a.name === b.name && a.size === b.size;
|
||||
}
|
||||
|
||||
function renderComprionDialog() {
|
||||
el('comprion-protocol-name').textContent =
|
||||
COMPRION_DLG.protocol ? COMPRION_DLG.protocol.name : t('no file selected');
|
||||
el('comprion-application-name').textContent =
|
||||
COMPRION_DLG.application ? COMPRION_DLG.application.name : t('no file selected');
|
||||
el('comprion-hint').textContent = COMPRION_DLG.application
|
||||
? t('The ISO Application Layer export adds exact per-APDU timestamps (ms precision) — the session window and the gaps between APDUs become exact. Both files are verified to belong to the same session before importing.')
|
||||
: t("Without the ISO Application Layer export the timeline is card/line activity time from the Protocol Layer's Delay column (the unit assumes 3.8 MHz) — the order is right, but host pauses are missing and the absolute times are not exact.");
|
||||
el('comprion-error').textContent = COMPRION_DLG.error
|
||||
? t(IMPORT_ERROR_KEYS[COMPRION_DLG.error] || COMPRION_DLG.error) : '';
|
||||
const busy = COMPRION_DLG.busy;
|
||||
el('comprion-protocol-btn').disabled = busy;
|
||||
el('comprion-application-btn').disabled = busy;
|
||||
el('comprion-cancel').disabled = busy;
|
||||
el('comprion-import').disabled = busy || !COMPRION_DLG.protocol;
|
||||
el('comprion-import').textContent = busy ? t('Importing…') : t('Import');
|
||||
}
|
||||
|
||||
function openComprionDialog() {
|
||||
COMPRION_DLG.protocol = null;
|
||||
COMPRION_DLG.application = null;
|
||||
COMPRION_DLG.busy = false;
|
||||
COMPRION_DLG.error = '';
|
||||
el('comprion-protocol-file').value = '';
|
||||
el('comprion-application-file').value = '';
|
||||
renderComprionDialog();
|
||||
el('comprion-modal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeComprionDialog() {
|
||||
if (COMPRION_DLG.busy) return;
|
||||
el('comprion-modal').classList.remove('show');
|
||||
}
|
||||
|
||||
async function comprionPick(slot, file) {
|
||||
if (!file) return;
|
||||
let kind = 'not_comprion';
|
||||
try {
|
||||
kind = comprionFileCheck(await file.slice(0, 8192).text());
|
||||
} catch (e) { /* unreadable: treat as unrelated */ }
|
||||
const other = slot === 'protocol' ? COMPRION_DLG.application : COMPRION_DLG.protocol;
|
||||
if (comprionSameFile(file, other)) {
|
||||
// The same export in both slots beats the generic slot mismatch.
|
||||
COMPRION_DLG[slot] = null;
|
||||
COMPRION_DLG.error = 'identical';
|
||||
} else {
|
||||
const error = comprionSlotCheck(slot, kind);
|
||||
if (error) {
|
||||
COMPRION_DLG[slot] = null;
|
||||
COMPRION_DLG.error = error;
|
||||
} else {
|
||||
COMPRION_DLG[slot] = file;
|
||||
COMPRION_DLG.error = '';
|
||||
}
|
||||
}
|
||||
renderComprionDialog();
|
||||
}
|
||||
|
||||
async function confirmComprionImport() {
|
||||
if (COMPRION_DLG.busy || !COMPRION_DLG.protocol) return;
|
||||
COMPRION_DLG.busy = true;
|
||||
COMPRION_DLG.error = '';
|
||||
renderComprionDialog();
|
||||
const file = COMPRION_DLG.protocol;
|
||||
const app = COMPRION_DLG.application;
|
||||
try {
|
||||
const data = app
|
||||
? await API.importComprionBundle(importBaseName(file),
|
||||
await file.text(), await app.text())
|
||||
: await API.importSession(importBaseName(file),
|
||||
await file.arrayBuffer(), 'comprion');
|
||||
el('comprion-modal').classList.remove('show');
|
||||
importMsg(importSuccessText(data));
|
||||
loadSessions();
|
||||
} catch (e) {
|
||||
// Keep the dialog (and both selections) so the user can fix them.
|
||||
COMPRION_DLG.error = (e && e.code && IMPORT_ERROR_KEYS[e.code])
|
||||
? e.code : ((e && e.message) || 'Import failed');
|
||||
} finally {
|
||||
COMPRION_DLG.busy = false;
|
||||
renderComprionDialog();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2476,7 +2682,8 @@ function formatDuration(start, end) {
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function showDeleteModal(id) {
|
||||
@@ -2707,8 +2914,8 @@ function onFilterChange() {
|
||||
}
|
||||
|
||||
// ──────────────────── Versioning ────────────────────
|
||||
const PWA_VERSION = '2.1.2';
|
||||
const MIN_SERVER_VERSION = '1.0.0';
|
||||
const PWA_VERSION = '2.2.0';
|
||||
const MIN_SERVER_VERSION = '2.2.0';
|
||||
|
||||
function parseVersion(v) {
|
||||
const parts = String(v || '').trim().split('.').map(n => parseInt(n, 10));
|
||||
@@ -2764,6 +2971,42 @@ const LANG_RU = {
|
||||
'No saved sessions': 'Нет сохранённых сессий',
|
||||
'Start a capture from the Trace tab to create sessions.': 'Начните захват на вкладке «Трассировка», чтобы создать сессии.',
|
||||
'Import PCAP': 'Импорт PCAP',
|
||||
'Import COMPRION trace': 'Импорт трассы COMPRION',
|
||||
'Import the \'ISO Protocol Layer\' export, optionally with the matching \'ISO Application Layer\' export for exact timestamps.':
|
||||
'Импорт экспорта «ISO Protocol Layer», при наличии — с соответствующим экспортом «ISO Application Layer» для точных меток времени.',
|
||||
'Select the ISO Protocol Layer export and, if available, the matching ISO Application Layer export of the same session.':
|
||||
'Выберите экспорт «ISO Protocol Layer» и, если есть, соответствующий ему экспорт «ISO Application Layer» той же сессии.',
|
||||
'ISO Protocol Layer export (required)': 'Экспорт «ISO Protocol Layer» (обязательно)',
|
||||
'ISO Application Layer export (optional)': 'Экспорт «ISO Application Layer» (необязательно)',
|
||||
'Choose file…': 'Выбрать файл…',
|
||||
'no file selected': 'файл не выбран',
|
||||
'Import': 'Импортировать',
|
||||
'Without the ISO Application Layer export the timeline is card/line activity time from the Protocol Layer\'s Delay column (the unit assumes 3.8 MHz) — the order is right, but host pauses are missing and the absolute times are not exact.':
|
||||
'Без экспорта «ISO Application Layer» таймлайн — это время активности карты/линии по столбцу Delay из Protocol Layer (единица измерения предполагает 3,8 МГц) — порядок верен, но паузы хоста отсутствуют, а абсолютное время неточно.',
|
||||
'The ISO Application Layer export adds exact per-APDU timestamps (ms precision) — the session window and the gaps between APDUs become exact. Both files are verified to belong to the same session before importing.':
|
||||
'Экспорт «ISO Application Layer» добавляет точные метки времени по APDU (точность до мс) — окно сессии и интервалы между APDU становятся точными. Перед импортом проверяется, что файлы относятся к одной сессии.',
|
||||
'This is the ISO Protocol Layer export — select it in the first slot.':
|
||||
'Это экспорт «ISO Protocol Layer» — выберите его в первом поле.',
|
||||
'The two files are identical.': 'Это один и тот же файл.',
|
||||
'This is the \'ISO Application Layer\' export — select the \'ISO Protocol Layer\' file (it contains the raw APDU bytes).':
|
||||
'Это экспорт «ISO Application Layer» — выберите файл «ISO Protocol Layer» (в нём есть необработанные APDU-байты).',
|
||||
'Not a COMPRION export — expected the \'ISO Protocol Layer\' text file.':
|
||||
'Это не экспорт COMPRION — ожидается текстовый файл «ISO Protocol Layer».',
|
||||
'Unsupported COMPRION export layout (expected the \'ISO Protocol Layer\' table).':
|
||||
'Неподдерживаемый формат экспорта COMPRION (ожидается таблица «ISO Protocol Layer»).',
|
||||
'No APDU exchanges found in the COMPRION export.':
|
||||
'В экспорте COMPRION не найдено ни одного APDU-обмена.',
|
||||
'No usable Time entries found in the ISO Application Layer export.':
|
||||
'В экспорте «ISO Application Layer» не найдено пригодных записей Time.',
|
||||
'The two files belong to different sessions — select the ISO Application Layer export of the same session.':
|
||||
'Файлы относятся к разным сессиям — выберите экспорт «ISO Application Layer» той же сессии.',
|
||||
'Cannot verify that the two files belong to the same session (no Session Name in either file).':
|
||||
'Не удалось убедиться, что файлы относятся к одной сессии (ни в одном нет Session Name).',
|
||||
'The ISO Application Layer file does not match this trace (different exchange count).':
|
||||
'Файл «ISO Application Layer» не соответствует этой трассировке (другое число обменов).',
|
||||
'This looks like a COMPRION export — use \'Import COMPRION trace\'.':
|
||||
'Похоже на экспорт COMPRION — используйте «Импорт трассы COMPRION».',
|
||||
'exact timestamps (Application Layer)': 'точные метки времени (ISO Application Layer)',
|
||||
'New capture': 'Новый захват',
|
||||
'Tiles': 'Плитки',
|
||||
'List': 'Список',
|
||||
@@ -2848,6 +3091,9 @@ function translatePage() {
|
||||
el.textContent = t(key);
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('[data-l10n-title]').forEach(el => {
|
||||
el.setAttribute('title', t(el.getAttribute('data-l10n-title')));
|
||||
});
|
||||
}
|
||||
|
||||
function applyLang() {
|
||||
@@ -2867,6 +3113,7 @@ function toggleLang() {
|
||||
renderMessages();
|
||||
loadSessions();
|
||||
checkConnection();
|
||||
if (el('comprion-modal').classList.contains('show')) renderComprionDialog();
|
||||
}
|
||||
|
||||
// ──────────────────── Theme ────────────────────
|
||||
@@ -2979,6 +3226,25 @@ function init() {
|
||||
if (file) importPcapFile(file);
|
||||
el('import-pcap-file').value = '';
|
||||
});
|
||||
$on('import-comprion-btn', 'click', openComprionDialog);
|
||||
$on('comprion-protocol-btn', 'click', () => el('comprion-protocol-file').click());
|
||||
$on('comprion-application-btn', 'click', () => el('comprion-application-file').click());
|
||||
$on('comprion-protocol-file', 'change', async () => {
|
||||
await comprionPick('protocol', el('comprion-protocol-file').files[0]);
|
||||
el('comprion-protocol-file').value = '';
|
||||
});
|
||||
$on('comprion-application-file', 'change', async () => {
|
||||
await comprionPick('application', el('comprion-application-file').files[0]);
|
||||
el('comprion-application-file').value = '';
|
||||
});
|
||||
$on('comprion-cancel', 'click', closeComprionDialog);
|
||||
$on('comprion-import', 'click', confirmComprionImport);
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape'
|
||||
&& el('comprion-modal').classList.contains('show')) {
|
||||
closeComprionDialog();
|
||||
}
|
||||
});
|
||||
|
||||
$on('view-grid-btn', 'click', () => toggleSessionView('grid'));
|
||||
$on('view-list-btn', 'click', () => toggleSessionView('list'));
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simtrace-analyser-v57';
|
||||
const CACHE = 'simtrace-analyser-v61';
|
||||
|
||||
const PRECACHE = [
|
||||
'index.html',
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { loadSrc, extractConst, extractFunc } = require('./helpers.js');
|
||||
|
||||
const src = loadSrc();
|
||||
|
||||
// t() is a lookup stub: the tests assert on keys, not translations.
|
||||
// The map getter keeps the const reachable outside the eval scope.
|
||||
eval(
|
||||
'function t(x) { return x; }\n' +
|
||||
extractConst(src, 'IMPORT_ERROR_KEYS') + '\n' +
|
||||
extractFunc(src, 'comprionFileCheck') + '\n' +
|
||||
extractFunc(src, 'importErrorText') + '\n' +
|
||||
extractFunc(src, 'comprionSlotCheck') + '\n' +
|
||||
'function __errorKeys() { return IMPORT_ERROR_KEYS; }'
|
||||
);
|
||||
|
||||
const PROTOCOL_HEAD = [
|
||||
'\t\t\t\t\t\t\t16.10.2026 15:10',
|
||||
'ISO Protocol Layer\t\t\t\t\t',
|
||||
'\t\tConfiguration\t\t\t',
|
||||
'\t\tUser:\t\tTest',
|
||||
'\t\tSession Name:\t\t2026-10-16 12h15m53s_AM_Android',
|
||||
'#\tSequence\tDir\tDescription\tValue\tDelay\tMeaning',
|
||||
'1\tCold reset\t\tEvent\t\t\tReset card interface',
|
||||
].join('\r\n');
|
||||
|
||||
test('comprionFileCheck: protocol layer export passes', () => {
|
||||
assert.equal(comprionFileCheck(PROTOCOL_HEAD), '');
|
||||
});
|
||||
|
||||
test('comprionFileCheck: application layer export is rejected', () => {
|
||||
const head = PROTOCOL_HEAD.replace('ISO Protocol Layer', 'ISO Application Layer');
|
||||
assert.equal(comprionFileCheck(head), 'app_layer');
|
||||
});
|
||||
|
||||
test('comprionFileCheck: unrelated files are rejected', () => {
|
||||
assert.equal(comprionFileCheck(''), 'not_comprion');
|
||||
assert.equal(comprionFileCheck('hello world\n'), 'not_comprion');
|
||||
assert.equal(comprionFileCheck('#\tSequence\tDir\n1\tAPDU\n'), 'not_comprion');
|
||||
});
|
||||
|
||||
test('comprionFileCheck: only the file head is inspected', () => {
|
||||
// The check reads the first lines only; a title buried deep in a large
|
||||
// file is not detected (the server performs the full validation).
|
||||
const deep = Array(20).fill('padding').join('\n') + '\nISO Protocol Layer\n';
|
||||
assert.equal(comprionFileCheck(deep), 'not_comprion');
|
||||
});
|
||||
|
||||
test('importErrorText: server codes map to user-facing texts', () => {
|
||||
const keys = __errorKeys();
|
||||
for (const code of ['app_layer', 'not_comprion', 'unsupported_layout',
|
||||
'no_data', 'comprion_export']) {
|
||||
const text = importErrorText({ code, message: 'raw server text' });
|
||||
assert.equal(text, keys[code]);
|
||||
assert.ok(text.length > 10);
|
||||
}
|
||||
});
|
||||
|
||||
test('importErrorText: unknown codes fall back to the server text', () => {
|
||||
assert.equal(importErrorText({ code: 'weird', message: 'boom' }), 'boom');
|
||||
assert.equal(importErrorText(new Error('boom')), 'boom');
|
||||
assert.equal(importErrorText(undefined), 'Import failed');
|
||||
});
|
||||
|
||||
test('the PCAP import path is unchanged', () => {
|
||||
assert.match(src, /id="import-pcap-file"[^>]*accept="\.pcap,\.pcapng,\.cap"/);
|
||||
assert.match(src, /\$on\('import-pcap-btn', 'click', \(\) => el\('import-pcap-file'\)\.click\(\)\)/);
|
||||
// It still posts without a format (the server defaults to pcap).
|
||||
assert.match(src, /API\.importSession\(importBaseName\(file\), await file\.arrayBuffer\(\), ''\)/);
|
||||
});
|
||||
|
||||
test('translatePage renders title attributes', () => {
|
||||
assert.match(src, /querySelectorAll\('\[data-l10n-title\]'\)/);
|
||||
});
|
||||
|
||||
test('session names are escaped for attribute context', () => {
|
||||
// An imported Session Name reaches value="..." in the session list; a
|
||||
// quote must not break out of the attribute (local self-XSS).
|
||||
eval(extractFunc(src, 'escapeHtml'));
|
||||
assert.equal(escapeHtml('a"b'), 'a"b');
|
||||
assert.equal(escapeHtml("a'b"), 'a'b');
|
||||
assert.equal(escapeHtml('<x>&'), '<x>&');
|
||||
assert.match(src, /value="\$\{escapeHtml\(name\)\}"/,
|
||||
'the session name must stay escaped in the list/grid templates');
|
||||
});
|
||||
|
||||
test('COMPRION import strings carry Russian translations', () => {
|
||||
const lang = extractConst(src, 'LANG_RU');
|
||||
const keys = new Set();
|
||||
for (const m of lang.matchAll(/'((?:[^'\\]|\\.)*)'\s*:/g)) {
|
||||
keys.add(m[1].replace(/\\(['\\])/g, '$1'));
|
||||
}
|
||||
const strings = [
|
||||
...Object.values(__errorKeys()),
|
||||
'Import COMPRION trace',
|
||||
"Import the 'ISO Protocol Layer' export, optionally with the matching 'ISO Application Layer' export for exact timestamps.",
|
||||
'Select the ISO Protocol Layer export and, if available, the matching ISO Application Layer export of the same session.',
|
||||
'ISO Protocol Layer export (required)',
|
||||
'ISO Application Layer export (optional)',
|
||||
'Choose file…',
|
||||
'no file selected',
|
||||
'Import',
|
||||
'exact timestamps (Application Layer)',
|
||||
"Without the ISO Application Layer export the timeline is card/line activity time from the Protocol Layer's Delay column (the unit assumes 3.8 MHz) — the order is right, but host pauses are missing and the absolute times are not exact.",
|
||||
'The ISO Application Layer export adds exact per-APDU timestamps (ms precision) — the session window and the gaps between APDUs become exact. Both files are verified to belong to the same session before importing.',
|
||||
];
|
||||
for (const s of strings) {
|
||||
assert.ok(keys.has(s), 'no LANG_RU entry for: ' + s);
|
||||
}
|
||||
});
|
||||
|
||||
test('the COMPRION import is a two-slot dialog', () => {
|
||||
// Two separate pickers (a required Protocol Layer slot and an optional
|
||||
// Application Layer time-base slot), an Import button gated on the
|
||||
// protocol file, and an in-dialog error area.
|
||||
assert.match(src, /id="comprion-modal"/);
|
||||
assert.match(src, /id="comprion-protocol-file"[^>]*accept="\.txt,\.tsv"/);
|
||||
assert.match(src, /id="comprion-application-file"[^>]*accept="\.txt,\.tsv"/);
|
||||
assert.match(src, /id="comprion-import"[^>]*disabled/);
|
||||
assert.doesNotMatch(src, /import-comprion-file"/);
|
||||
assert.doesNotMatch(src, /classifyComprionFiles/);
|
||||
assert.match(src, /importComprionBundle/);
|
||||
assert.match(src, /time_base === 'application-layer'/);
|
||||
assert.match(src, /\$on\('import-comprion-btn', 'click', openComprionDialog\)/);
|
||||
assert.match(src, /\$on\('comprion-cancel', 'click', closeComprionDialog\)/);
|
||||
assert.match(src, /e\.key === 'Escape'/);
|
||||
});
|
||||
|
||||
test('comprionSlotCheck: slot/kind outcomes', () => {
|
||||
assert.equal(comprionSlotCheck('protocol', ''), '');
|
||||
assert.equal(comprionSlotCheck('protocol', 'app_layer'), 'app_layer');
|
||||
assert.equal(comprionSlotCheck('protocol', 'not_comprion'), 'not_comprion');
|
||||
assert.equal(comprionSlotCheck('application', 'app_layer'), '');
|
||||
assert.equal(comprionSlotCheck('application', ''), 'protocol_slot');
|
||||
assert.equal(comprionSlotCheck('application', 'not_comprion'), 'not_comprion');
|
||||
// Every outcome has a message.
|
||||
const keys = __errorKeys();
|
||||
for (const code of ['app_layer', 'protocol_slot', 'not_comprion', 'identical']) {
|
||||
assert.ok(keys[code], 'no message for: ' + code);
|
||||
}
|
||||
});
|
||||
|
||||
// ── dialog state machine (with a minimal DOM/API stub) ─────────────────
|
||||
const DLG_NODES = new Map();
|
||||
function dlgEl(id) {
|
||||
if (!DLG_NODES.has(id)) {
|
||||
const classes = new Set();
|
||||
DLG_NODES.set(id, {
|
||||
id, textContent: '', disabled: false, value: '', files: [],
|
||||
classList: {
|
||||
add: c => classes.add(c),
|
||||
remove: c => classes.delete(c),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
});
|
||||
}
|
||||
return DLG_NODES.get(id);
|
||||
}
|
||||
|
||||
eval(
|
||||
'var IMPORT_ERROR_KEYS = __errorKeys();\n' +
|
||||
'function el(id) { return dlgEl(id); }\n' +
|
||||
extractConst(src, 'COMPRION_DLG').replace('const ', 'var ') + '\n' +
|
||||
extractFunc(src, 'comprionSameFile') + '\n' +
|
||||
extractFunc(src, 'renderComprionDialog') + '\n' +
|
||||
extractFunc(src, 'openComprionDialog') + '\n' +
|
||||
extractFunc(src, 'closeComprionDialog') + '\n' +
|
||||
extractFunc(src, 'comprionPick') + '\n' +
|
||||
extractFunc(src, 'importBaseName') + '\n' +
|
||||
extractFunc(src, 'importSuccessText') + '\n' +
|
||||
extractFunc(src, 'confirmComprionImport') + '\n' +
|
||||
'var IMPORT_CALLS = []; var IMPORT_FAIL = null; var IMPORT_MSG = [];\n' +
|
||||
'var API = {\n' +
|
||||
' importComprionBundle: async (name, text, times) => {\n' +
|
||||
' IMPORT_CALLS.push(["bundle", name, text.length, times.length]);\n' +
|
||||
' if (IMPORT_FAIL) throw IMPORT_FAIL;\n' +
|
||||
' return {name: "S", message_count: 7, time_base: "application-layer"};\n' +
|
||||
' },\n' +
|
||||
' importSession: async (name, bytes, fmt) => {\n' +
|
||||
' IMPORT_CALLS.push(["raw", name, fmt]);\n' +
|
||||
' if (IMPORT_FAIL) throw IMPORT_FAIL;\n' +
|
||||
' return {name: "S", message_count: 3, time_base: "delays"};\n' +
|
||||
' },\n' +
|
||||
'};\n' +
|
||||
'function importMsg(text, error) { IMPORT_MSG.push([text, !!error]); }\n' +
|
||||
'function loadSessions() {}\n'
|
||||
);
|
||||
|
||||
function enactFile(name, head) {
|
||||
return {
|
||||
name, size: head.length + 1,
|
||||
slice: () => ({ text: async () => head }),
|
||||
text: async () => head,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
};
|
||||
}
|
||||
|
||||
const APP_HEAD = PROTOCOL_HEAD.replace('ISO Protocol Layer', 'ISO Application Layer');
|
||||
|
||||
test('COMPRION dialog: slot validation and import gating', async () => {
|
||||
openComprionDialog();
|
||||
assert.ok(el('comprion-modal').classList.contains('show'));
|
||||
assert.equal(el('comprion-import').disabled, true, 'no file -> no import');
|
||||
assert.equal(el('comprion-protocol-name').textContent, 'no file selected');
|
||||
|
||||
// Wrong layer in the protocol slot: error, slot stays empty.
|
||||
await comprionPick('protocol', enactFile('app.txt', APP_HEAD));
|
||||
assert.equal(COMPRION_DLG.protocol, null);
|
||||
assert.equal(COMPRION_DLG.error, 'app_layer');
|
||||
assert.equal(el('comprion-import').disabled, true);
|
||||
|
||||
// The Protocol Layer export in the protocol slot.
|
||||
const proto = enactFile('proto.txt', PROTOCOL_HEAD);
|
||||
await comprionPick('protocol', proto);
|
||||
assert.equal(COMPRION_DLG.error, '');
|
||||
assert.equal(el('comprion-protocol-name').textContent, 'proto.txt');
|
||||
assert.equal(el('comprion-import').disabled, false);
|
||||
|
||||
// The Protocol Layer export in the application slot.
|
||||
await comprionPick('application', enactFile('p2.txt', PROTOCOL_HEAD));
|
||||
assert.equal(COMPRION_DLG.application, null);
|
||||
assert.equal(COMPRION_DLG.error, 'protocol_slot');
|
||||
|
||||
// An unrelated file in the application slot.
|
||||
await comprionPick('application', enactFile('x.txt', 'hello'));
|
||||
assert.equal(COMPRION_DLG.error, 'not_comprion');
|
||||
|
||||
// The same file in both slots.
|
||||
await comprionPick('application', enactFile('proto.txt', PROTOCOL_HEAD));
|
||||
assert.equal(COMPRION_DLG.application, null);
|
||||
assert.equal(COMPRION_DLG.error, 'identical');
|
||||
assert.notEqual(COMPRION_DLG.protocol, null);
|
||||
|
||||
// A valid application file clears the error.
|
||||
await comprionPick('application', enactFile('app.txt', APP_HEAD));
|
||||
assert.equal(COMPRION_DLG.error, '');
|
||||
assert.ok(el('comprion-hint').textContent.includes('exact per-APDU timestamps'));
|
||||
|
||||
closeComprionDialog();
|
||||
assert.equal(el('comprion-modal').classList.contains('show'), false);
|
||||
});
|
||||
|
||||
test('COMPRION dialog: success closes, server errors stay', async () => {
|
||||
// Success with both files: one JSON bundle, dialog closed.
|
||||
openComprionDialog();
|
||||
await comprionPick('protocol', enactFile('proto.txt', PROTOCOL_HEAD));
|
||||
await comprionPick('application', enactFile('app.txt', APP_HEAD));
|
||||
IMPORT_CALLS.length = 0;
|
||||
await confirmComprionImport();
|
||||
assert.deepEqual(IMPORT_CALLS,
|
||||
[['bundle', 'proto', PROTOCOL_HEAD.length, APP_HEAD.length]]);
|
||||
assert.equal(el('comprion-modal').classList.contains('show'), false);
|
||||
assert.ok(IMPORT_MSG.some(([text]) => text.includes('exact timestamps')));
|
||||
|
||||
// Protocol Layer only: the raw-body path.
|
||||
openComprionDialog();
|
||||
await comprionPick('protocol', enactFile('proto.txt', PROTOCOL_HEAD));
|
||||
IMPORT_CALLS.length = 0;
|
||||
await confirmComprionImport();
|
||||
assert.deepEqual(IMPORT_CALLS, [['raw', 'proto', 'comprion']]);
|
||||
assert.equal(el('comprion-modal').classList.contains('show'), false);
|
||||
|
||||
// A server error keeps the dialog open with both selections intact.
|
||||
openComprionDialog();
|
||||
await comprionPick('protocol', enactFile('proto.txt', PROTOCOL_HEAD));
|
||||
await comprionPick('application', enactFile('app.txt', APP_HEAD));
|
||||
IMPORT_FAIL = Object.assign(new Error('mismatch'), { code: 'session_mismatch' });
|
||||
await confirmComprionImport();
|
||||
IMPORT_FAIL = null;
|
||||
assert.equal(el('comprion-modal').classList.contains('show'), true);
|
||||
assert.equal(COMPRION_DLG.error, 'session_mismatch');
|
||||
assert.ok(COMPRION_DLG.protocol && COMPRION_DLG.application);
|
||||
assert.equal(el('comprion-import').disabled, false, 'import can be retried');
|
||||
|
||||
closeComprionDialog();
|
||||
});
|
||||
Reference in new issue
Block a user