diff --git a/frontend/index.html b/frontend/index.html
index 2b0758b..46b3edd 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1161,14 +1161,28 @@ const API = {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
+ let res;
try {
- const res = await fetch(API_BASE() + path, opts);
- return await res.json();
+ res = await fetch(API_BASE() + path, opts);
} catch (e) {
+ // The server is unreachable (network error) — that *is* a
+ // disconnected state.
STATE.serverAvailable = false;
updateCaptureButton();
throw e;
}
+ let data = {};
+ try {
+ data = await res.json();
+ } catch (e) { /* non-JSON body */ }
+ if (!res.ok) {
+ // The server answers errors as {"error": …}; surface them instead
+ // of letting callers treat the error body as data.
+ throw Object.assign(
+ new Error((data && data.error) || `HTTP ${res.status}`),
+ { status: res.status, code: data && data.code });
+ }
+ return data;
},
status() { return this._fetch('GET', '/api/status'); },
listSessions() { return this._fetch('GET', '/api/sessions'); },
@@ -2105,19 +2119,31 @@ async function renderFileView() {
view.innerHTML = `
${t('Open a saved session from the Sessions tab.')}
`;
return;
}
- if (STATE.filesData === null || STATE.filesSessionId !== STATE.sessionViewId) {
+ const sid = STATE.sessionViewId;
+ let data = STATE.filesData;
+ // A failure is never cached: only a well-formed payload is remembered, so
+ // the next visit retries instead of showing a stale error.
+ if (data === null || STATE.filesSessionId !== sid ||
+ !Array.isArray(data.timeline)) {
view.innerHTML = `${t('Loading…')}
`;
try {
- STATE.filesData = await API.getSessionFiles(STATE.sessionViewId);
- STATE.filesSessionId = STATE.sessionViewId;
+ const payload = await API.getSessionFiles(sid);
+ if (STATE.sessionViewId !== sid) return; // the view moved on
+ if (!payload || !Array.isArray(payload.timeline)) {
+ throw new Error((payload && payload.error) || t('Failed to load files'));
+ }
+ data = payload;
+ STATE.filesData = payload;
+ STATE.filesSessionId = sid;
} catch (e) {
+ if (STATE.sessionViewId !== sid) return;
STATE.filesData = null;
- view.innerHTML = `${t('Failed to load files')}
`;
+ STATE.filesSessionId = null;
+ view.innerHTML = `${t('Failed to load files')}: ${escapeHtml((e && e.message) || '')}
`;
return;
}
}
- const data = STATE.filesData;
- const timeline = (data && data.timeline) || [];
+ const timeline = data.timeline;
if (!timeline.length) {
view.innerHTML = `${t('No file operations recorded')}
`;
return;
@@ -3046,7 +3072,7 @@ function onFilterChange() {
}
// ──────────────────── Versioning ────────────────────
-const PWA_VERSION = '2.3.0';
+const PWA_VERSION = '2.3.1';
const MIN_SERVER_VERSION = '2.3.0';
function parseVersion(v) {
diff --git a/frontend/sw.js b/frontend/sw.js
index 2b8828d..00a5583 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simtrace-analyser-v63';
+const CACHE = 'simtrace-analyser-v64';
const PRECACHE = [
'index.html',
diff --git a/frontend/tests/comprion.test.js b/frontend/tests/comprion.test.js
index 5b5b3ee..a24ecbe 100644
--- a/frontend/tests/comprion.test.js
+++ b/frontend/tests/comprion.test.js
@@ -320,7 +320,7 @@ test('Files view: side-panel dropdown and the timeline are wired', () => {
assert.match(src, /id="file-diff-body"/);
assert.match(src, /\$on\('side-view-select', 'change'/);
assert.match(src, /\$on\('file-view', 'click'/);
- assert.match(src, /API\.getSessionFiles\(STATE\.sessionViewId\)/);
+ assert.match(src, /API\.getSessionFiles\(sid\)/);
assert.match(src, /if \(row\) jumpToMessage\(Number\(row\.dataset\.idx\)\);/);
assert.match(src, /getSessionFiles\(id\)\s*\{ return this\._fetch\('GET', `\/api\/sessions\/\$\{id\}\/files`\); \}/);
});
@@ -337,3 +337,97 @@ test('Files view: strings carry Russian translations', () => {
assert.ok(keys.has(s), 'no LANG_RU entry for: ' + s);
}
});
+
+// ── Files view against the real API layer (fetch stubbed) ──────────────
+// Reuses the t()/el() stubs from the dialog block above.
+eval(
+ 'var localStorage = { getItem: () => "", setItem: () => {} };\n' +
+ extractConst(src, 'STATE').replace('const ', 'var ') + '\n' +
+ 'function API_BASE() { return "http://test"; }\n' +
+ 'function updateCaptureButton() {}\n' +
+ 'var __fetchImpl = null;\n' +
+ 'var fetch = (...a) => __fetchImpl(...a);\n' +
+ extractConst(src, 'API') + '\n' +
+ extractFunc(src, 'escapeHtml') + '\n' +
+ extractFunc(src, 'fileOpClass') + '\n' +
+ extractFunc(src, 'fileOpVerb') + '\n' +
+ extractFunc(src, 'fileOpLabel') + '\n' +
+ extractFunc(src, 'renderFileView') + '\n' +
+ 'function __filesApi() { return API; }'
+);
+
+function fetchReply(status, body) {
+ return async () => ({
+ ok: status >= 200 && status < 300, status,
+ json: async () => body,
+ });
+}
+
+test('API._fetch: an HTTP error throws and does not look like data', async () => {
+ const api = __filesApi();
+ __fetchImpl = fetchReply(200, {a: 1});
+ assert.deepEqual(await api._fetch('GET', '/x'), {a: 1});
+ STATE.serverAvailable = true;
+ __fetchImpl = fetchReply(404, {error: 'Session not found'});
+ await assert.rejects(() => api._fetch('GET', '/y'), /Session not found/);
+ assert.equal(STATE.serverAvailable, true, 'HTTP errors are not disconnects');
+ __fetchImpl = async () => { throw new Error('network'); };
+ await assert.rejects(() => api._fetch('GET', '/z'), /network/);
+ assert.equal(STATE.serverAvailable, false, 'network errors do');
+});
+
+test('Files view: a valid payload renders the timeline', async () => {
+ __fetchImpl = fetchReply(200, {
+ timeline: [{kind: 'read', index: 3, file: {name: 'EF_ICCID'}}],
+ files: {}, timeline_total: 1,
+ });
+ STATE.sessionViewId = 7; STATE.filesData = null; STATE.filesSessionId = null;
+ await renderFileView();
+ assert.match(el('file-view').innerHTML, /Read EF_ICCID/);
+ assert.equal(STATE.filesSessionId, 7);
+});
+
+test('Files view: an empty payload is the empty state, not an error', async () => {
+ __fetchImpl = fetchReply(200, {timeline: [], files: {}, timeline_total: 0});
+ STATE.sessionViewId = 8; STATE.filesData = null; STATE.filesSessionId = null;
+ await renderFileView();
+ assert.match(el('file-view').innerHTML, /No file operations recorded/);
+ assert.doesNotMatch(el('file-view').innerHTML, /Failed to load files/);
+});
+
+test('Files view: an error is shown with its reason and never cached', async () => {
+ let calls = 0;
+ __fetchImpl = async () => {
+ calls++;
+ return {ok: false, status: 404, json: async () => ({error: 'Session not found'})};
+ };
+ STATE.sessionViewId = 9; STATE.filesData = null; STATE.filesSessionId = null;
+ await renderFileView();
+ assert.match(el('file-view').innerHTML, /Failed to load files/);
+ assert.match(el('file-view').innerHTML, /Session not found/);
+ assert.equal(STATE.filesData, null, 'a failure must not be cached');
+ assert.equal(STATE.filesSessionId, null);
+ // The next visit retries (and this time succeeds).
+ __fetchImpl = fetchReply(200, {
+ timeline: [{kind: 'update', index: 1, file: {name: 'EF_LOCI'}}],
+ files: {}, timeline_total: 1,
+ });
+ await renderFileView();
+ assert.match(el('file-view').innerHTML, /Update EF_LOCI/);
+ assert.equal(calls, 1);
+});
+
+test('Files view: a response for a session left meanwhile is ignored', async () => {
+ let resolveIt;
+ const pending = new Promise(r => { resolveIt = r; });
+ __fetchImpl = async () => ({ok: true, status: 200, json: () => pending});
+ STATE.sessionViewId = 10; STATE.filesData = null; STATE.filesSessionId = null;
+ const render = renderFileView();
+ STATE.sessionViewId = 11; // the user opened another session meanwhile
+ resolveIt({timeline: [{kind: 'read', index: 0, file: {name: 'STALE'}}],
+ files: {}, timeline_total: 1});
+ await render;
+ assert.doesNotMatch(el('file-view').innerHTML, /STALE/);
+ assert.equal(STATE.filesData, null);
+ assert.equal(STATE.filesSessionId, null);
+});
diff --git a/pyproject.toml b/pyproject.toml
index 76ab1bf..715027d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "simtrace-analyser"
-version = "2.3.0"
+version = "2.3.1"
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "GPL-2.0-or-later"}
diff --git a/simtrace_analyser/version.py b/simtrace_analyser/version.py
index caaafd7..b2b5993 100644
--- a/simtrace_analyser/version.py
+++ b/simtrace_analyser/version.py
@@ -1,3 +1,3 @@
"""Single source of truth for the SIMtrace Analyser version."""
-__version__ = '2.3.0'
+__version__ = '2.3.1'
diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py
index 5940349..e6d748d 100644
--- a/tests/test_pcap_import.py
+++ b/tests/test_pcap_import.py
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
class TestVersion(unittest.TestCase):
def test_package_version(self):
from simtrace2_pysniff import __version__
- self.assertEqual(__version__, '2.3.0')
+ self.assertEqual(__version__, '2.3.1')
def test_status_includes_version(self):
import os