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