Files view: surface fetch errors instead of "no operations" (v2.3.1)
- `API._fetch` never checked the HTTP status: an error body (e.g. the 404
`{"error": "Session not found"}`) was returned as data, so the Files view
rendered it as "No file operations recorded" — and, worse, that bad payload
was cached per session id, which is why only re-opening the session (which
resets the cache) appeared to fix it. It now throws with the server's
message for any non-OK response (all 13 `await API.*` call sites are inside
try/catch) and marks the server unavailable only for a real network error
- `renderFileView` accepts a payload only when it carries a `timeline` array:
a genuine `timeline_total === 0` keeps the empty state, anything else shows
"Failed to load files: <reason>"; failures are not cached, so the next visit
retries; a response that arrives after the user switched sessions is ignored
- tests: HTTP-error vs network-error behaviour of `_fetch` (network error is
the only disconnected state), the valid/empty/error render paths and the
retry-after-error, the stale-response guard
- v2.3.1; SW cache simtrace-analyser-v64
This commit is contained in:
1 parent
c494bf4111
commit
87b6811adf
6 files changed
+134
-14
No files matched your search
+35
-9
@@ -1161,14 +1161,28 @@ const API = {
|
|||||||
opts.headers['Content-Type'] = 'application/json';
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
opts.body = JSON.stringify(body);
|
opts.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
|
let res;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API_BASE() + path, opts);
|
res = await fetch(API_BASE() + path, opts);
|
||||||
return await res.json();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// The server is unreachable (network error) — that *is* a
|
||||||
|
// disconnected state.
|
||||||
STATE.serverAvailable = false;
|
STATE.serverAvailable = false;
|
||||||
updateCaptureButton();
|
updateCaptureButton();
|
||||||
throw e;
|
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'); },
|
status() { return this._fetch('GET', '/api/status'); },
|
||||||
listSessions() { return this._fetch('GET', '/api/sessions'); },
|
listSessions() { return this._fetch('GET', '/api/sessions'); },
|
||||||
@@ -2105,19 +2119,31 @@ async function renderFileView() {
|
|||||||
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('Open a saved session from the Sessions tab.')}</div>`;
|
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('Open a saved session from the Sessions tab.')}</div>`;
|
||||||
return;
|
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 = `<div class="empty-state" style="height:auto;padding:24px;">${t('Loading…')}</div>`;
|
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('Loading…')}</div>`;
|
||||||
try {
|
try {
|
||||||
STATE.filesData = await API.getSessionFiles(STATE.sessionViewId);
|
const payload = await API.getSessionFiles(sid);
|
||||||
STATE.filesSessionId = STATE.sessionViewId;
|
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) {
|
} catch (e) {
|
||||||
|
if (STATE.sessionViewId !== sid) return;
|
||||||
STATE.filesData = null;
|
STATE.filesData = null;
|
||||||
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('Failed to load files')}</div>`;
|
STATE.filesSessionId = null;
|
||||||
|
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('Failed to load files')}: ${escapeHtml((e && e.message) || '')}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const data = STATE.filesData;
|
const timeline = data.timeline;
|
||||||
const timeline = (data && data.timeline) || [];
|
|
||||||
if (!timeline.length) {
|
if (!timeline.length) {
|
||||||
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('No file operations recorded')}</div>`;
|
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('No file operations recorded')}</div>`;
|
||||||
return;
|
return;
|
||||||
@@ -3046,7 +3072,7 @@ function onFilterChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────── Versioning ────────────────────
|
// ──────────────────── Versioning ────────────────────
|
||||||
const PWA_VERSION = '2.3.0';
|
const PWA_VERSION = '2.3.1';
|
||||||
const MIN_SERVER_VERSION = '2.3.0';
|
const MIN_SERVER_VERSION = '2.3.0';
|
||||||
|
|
||||||
function parseVersion(v) {
|
function parseVersion(v) {
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simtrace-analyser-v63';
|
const CACHE = 'simtrace-analyser-v64';
|
||||||
|
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'index.html',
|
'index.html',
|
||||||
|
|||||||
@@ -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, /id="file-diff-body"/);
|
||||||
assert.match(src, /\$on\('side-view-select', 'change'/);
|
assert.match(src, /\$on\('side-view-select', 'change'/);
|
||||||
assert.match(src, /\$on\('file-view', 'click'/);
|
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, /if \(row\) jumpToMessage\(Number\(row\.dataset\.idx\)\);/);
|
||||||
assert.match(src, /getSessionFiles\(id\)\s*\{ return this\._fetch\('GET', `\/api\/sessions\/\$\{id\}\/files`\); \}/);
|
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);
|
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);
|
||||||
|
});
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "simtrace-analyser"
|
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)"
|
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
|
||||||
readme = {file = "README.md", content-type = "text/markdown"}
|
readme = {file = "README.md", content-type = "text/markdown"}
|
||||||
license = {text = "GPL-2.0-or-later"}
|
license = {text = "GPL-2.0-or-later"}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the SIMtrace Analyser version."""
|
"""Single source of truth for the SIMtrace Analyser version."""
|
||||||
|
|
||||||
__version__ = '2.3.0'
|
__version__ = '2.3.1'
|
||||||
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
|
|||||||
class TestVersion(unittest.TestCase):
|
class TestVersion(unittest.TestCase):
|
||||||
def test_package_version(self):
|
def test_package_version(self):
|
||||||
from simtrace2_pysniff import __version__
|
from simtrace2_pysniff import __version__
|
||||||
self.assertEqual(__version__, '2.3.0')
|
self.assertEqual(__version__, '2.3.1')
|
||||||
|
|
||||||
def test_status_includes_version(self):
|
def test_status_includes_version(self):
|
||||||
import os
|
import os
|
||||||
|
|||||||
Reference in new issue
Block a user