add offline support
Sonarqube Scanner / Prepare environment (push) Successful in 14s
Sonarqube Scanner / Build and test (push) Successful in 22s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m14s
Sonarqube Scanner / Deploy to Server (push) Successful in 11s

This commit is contained in:
Florian Zumpe
2026-09-30 23:27:42 +02:00
parent 6913024af1
commit 8f4664282b
16 changed files with 1134 additions and 4 deletions
+403
View File
@@ -0,0 +1,403 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import vm from 'node:vm';
import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import { offlineManifest } from '../src/services/offline-manifest.js';
const root = path.resolve(import.meta.dirname, '..');
const origin = 'https://wegwichtel.test';
function memoryCaches() {
const stores = new Map();
const key = value => new URL(value.url ?? value, origin).href;
return {
stores,
async open(name) {
if (!stores.has(name)) stores.set(name, new Map());
const items = stores.get(name);
return {
async put(url, response) { items.set(key(url), response.clone()); },
async match(url) { return items.get(key(url))?.clone(); },
async delete(url) { return items.delete(key(url)); }
};
},
async delete(name) { return stores.delete(name); }
};
}
function memoryDirectory() {
const files = new Map();
const directories = new Map();
return {
files, directories,
async getDirectoryHandle(name, options = {}) {
if (!directories.has(name)) {
if (!options.create) throw new Error('Missing directory');
directories.set(name, memoryDirectory());
}
return directories.get(name);
},
async getFileHandle(name, options = {}) {
if (!files.has(name) && !options.create) throw new Error('Missing file');
return {
async createWritable() {
let contents;
return {
async write(value) { contents = value; },
async close() { files.set(name, new Blob([contents])); },
async abort() {}
};
},
async getFile() { return files.get(name); }
};
},
async removeEntry(name) { directories.delete(name); files.delete(name); }
};
}
async function loadStore(filesystem = false) {
const directory = memoryDirectory();
const context = {
location: { origin }, navigator: { storage: filesystem ? { getDirectory: async () => directory } : {} },
caches: memoryCaches(), crypto, URL, Response, Headers, Blob, Date
};
context.globalThis = context;
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/offline-storage.js'), 'utf8'), context, { filename: path.join(root, 'public/js/offline-storage.js') });
return { context, directory, store: context.Wegwichtel.OfflineStore };
}
test('app offline manifest includes app assets but no route APIs, GPX or route media', async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'wegwichtel-manifest-'));
const db = new DatabaseSync(':memory:');
try {
db.exec(await fs.readFile(path.join(root, 'database/schema.sql'), 'utf8'));
db.exec(`INSERT INTO routes(id,slug,name,gpx_path,activated_at,status) VALUES
(1,'released','Released','one.gpx','2026-09-30','active'),
(2,'draft','Draft','two.gpx',NULL,'active'),
(3,'deleted','Deleted','three.gpx','2026-09-30','deleted');
INSERT INTO pois(id,route_id,title,lat,lon,audio_path) VALUES (1,1,'POI',50,10,'audio.mp3');
INSERT INTO poi_images(id,poi_id,path) VALUES(1,1,'image.png');`);
await fs.mkdir(path.join(directory, 'images', 'group-symbols'), { recursive: true });
await fs.mkdir(path.join(directory, 'routes'));
await fs.writeFile(path.join(directory, 'routes', 'photo.png'), 'route media');
await fs.writeFile(path.join(directory, 'track.gpx'), 'track');
await fs.writeFile(path.join(directory, 'index.html'), 'App');
await fs.writeFile(path.join(directory, 'images', 'group-symbols', '🍓.png'), 'image');
await fs.writeFile(path.join(directory, '.private'), 'hidden');
const { resources } = offlineManifest(directory);
assert.ok(resources.includes('/'));
assert.ok(resources.includes('/index.html'));
assert.ok(resources.includes('/images/group-symbols/%F0%9F%8D%93.png'));
assert.ok(!resources.includes('/api/health'));
assert.ok(!resources.includes('/api/routes?activatedOnly=true'));
assert.ok(!resources.includes('/api/routes/1/gpx'));
assert.ok(!resources.includes('/api/routes/1/pois/1/audio'));
assert.ok(!resources.includes('/api/routes/1/pois/1/pictures/1'));
assert.ok(!resources.includes('/.private'));
assert.ok(!resources.includes('/track.gpx'));
assert.ok(!resources.includes('/routes/photo.png'));
assert.ok(!resources.some(url => url.startsWith('/api/')));
assert.ok(!resources.includes('/api/routes/2'));
assert.ok(!resources.includes('/api/routes/3'));
} finally { db.close(); await fs.rm(directory, { recursive: true, force: true }); }
});
for (const filesystem of [false, true]) {
test(`offline ${filesystem ? 'filesystem' : 'cache'} saves bytes/headers, switches snapshots atomically and removes only its own data`, async () => {
const { context, directory, store } = await loadStore(filesystem);
const unrelated = await context.caches.open('other-cache');
await unrelated.put('/other', new Response('keep'));
assert.equal(await store.state(), null);
assert.equal(await store.match('/image.png'), null);
const first = await store.create();
assert.equal(first.mode, filesystem ? 'filesystem' : 'cache');
await store.put(first, '/image.png?v=1', new Response('first', { headers: { 'Content-Type': 'image/png' } }));
assert.equal(await store.match('/image.png'), null, 'partial downloads must not be visible');
await store.commit(first);
assert.equal(await (await store.match('/image.png?v=99')).text(), 'first');
assert.equal((await store.match('/image.png')).headers.get('Content-Type'), 'image/png');
const failed = await store.create();
await assert.rejects(store.put(failed, '/image.png', new Response('missing', { status: 404 })), /Download fehlgeschlagen/);
await store.remove(failed.id);
assert.equal(await (await store.match('/image.png')).text(), 'first');
assert.throws(() => store.key('https://outside.test/image.png'), /dieser App/);
const second = await store.create();
await store.put(second, '/image.png', new Response('second'));
await store.commit(second);
assert.equal(await (await store.match('/image.png')).text(), 'second');
assert.equal(context.caches.stores.has(first.id), false);
if (filesystem) assert.equal(directory.directories.get('wegwichtel-offline-v1').directories.has(first.id), false);
await store.clear();
assert.equal(await store.match('/image.png'), null);
assert.equal(await (await unrelated.match('/other')).text(), 'keep');
await store.remove('other-cache');
assert.equal(context.caches.stores.has('other-cache'), true);
});
}
async function workerHarness() {
const listeners = new Map();
const calls = [];
const context = {
location: { origin }, URL, Headers, Response,
Wegwichtel: { OfflineStore: { async match() { return new Response('abcdefghij', { headers: { 'Content-Type': 'audio/mpeg' } }); } } },
importScripts() {},
fetch: async request => { calls.push(request); return new Response('network'); },
addEventListener(name, handler) { listeners.set(name, handler); },
skipWaiting: async () => {}, clients: { claim: async () => {} }
};
context.self = context;
vm.runInNewContext(await fs.readFile(path.join(root, 'public/offline-worker.js'), 'utf8'), context, { filename: path.join(root, 'public/offline-worker.js') });
async function request(url, options) {
let response;
listeners.get('fetch')({ request: new Request(new URL(url, origin), options), respondWith(value) { response = value; } });
return response;
}
return { context, listeners, calls, request };
}
test('offline worker serves stored audio ranges including seeks, rejects invalid ranges and leaves writes/network downloads untouched', async () => {
const { context, listeners, calls, request } = await workerHarness();
for (const name of ['install', 'activate']) {
let pending;
listeners.get(name)({ waitUntil(value) { pending = value; } });
await pending;
}
assert.equal(await (await request('/audio')).text(), 'abcdefghij');
for (const [range, body, contentRange] of [
['bytes=2-5', 'cdef', 'bytes 2-5/10'], ['bytes=7-', 'hij', 'bytes 7-9/10'],
['bytes=-3', 'hij', 'bytes 7-9/10'], ['bytes=0-100', 'abcdefghij', 'bytes 0-9/10']
]) {
const response = await request('/audio', { headers: { Range: range } });
assert.equal(response.status, 206);
assert.equal(response.headers.get('Content-Range'), contentRange);
assert.equal(await response.text(), body);
}
for (const range of ['bytes=50-', 'bytes=8-2', 'bytes=-0', 'bytes=-', 'garbage']) {
assert.equal((await request('/audio', { headers: { Range: range } })).status, 416);
}
assert.equal(await request('/audio', { method: 'POST' }), undefined);
assert.equal(await request('https://outside.test/audio'), undefined);
assert.equal(await request('/audio', { headers: { 'X-Wegwichtel-Offline-Download': 'true' } }), undefined);
assert.equal(await request('/api/offline-manifest'), undefined);
context.Wegwichtel.OfflineStore.match = async () => null;
assert.equal(await (await request('/missing')).text(), 'network');
context.Wegwichtel.OfflineStore.match = async () => { throw new Error('Disk unavailable'); };
assert.equal(await (await request('/missing')).text(), 'network');
assert.equal(calls.length, 2);
});
async function managerHarness({ supported = true, saved = false } = {}) {
const { context, store } = await loadStore(true);
const elements = new Map();
for (const id of ['offline-cache-toggle', 'offline-cache-status', 'offline-cache-refresh', 'bootstrap-enter']) {
const element = new EventTarget();
element.attributes = new Map();
element.setAttribute = (name, value) => element.attributes.set(name, value);
elements.set(id, element);
}
context.document = { getElementById: id => elements.get(id) };
context.isSecureContext = supported;
context.AbortController = AbortController;
context.setTimeout = setTimeout;
context.clearTimeout = clearTimeout;
context.navigator.serviceWorker = {
controller: {}, ready: Promise.resolve(),
async register(url, options) { assert.equal(url, '/offline-worker.js'); assert.equal(options.scope, '/'); }
};
context.navigator.storage.persist = async () => false;
const downloaded = [];
context.fetch = async (url, options) => {
assert.equal(options.cache, 'no-store');
assert.equal(options.headers['X-Wegwichtel-Offline-Download'], 'true');
if (url === '/api/offline-manifest') return Response.json({ resources: ['/', '/js/app.js', '/image.png', '/image.png'] });
downloaded.push(url);
return new Response(`content:${url}`);
};
if (saved) {
const stage = await store.create();
await store.put(stage, '/', new Response('old'));
await store.commit(stage);
}
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/offline-cache.js'), 'utf8'), context, { filename: path.join(root, 'public/js/offline-cache.js') });
return { context, store, elements, downloaded, manager: context.Wegwichtel.OfflineCache };
}
test('offline switch prepares all resources once, persists opt-in and clears only offline copies', async () => {
const { manager, store, elements, downloaded } = await managerHarness();
await manager.initialize();
assert.equal(elements.get('offline-cache-toggle').checked, false);
elements.get('offline-cache-toggle').checked = true;
elements.get('offline-cache-toggle').dispatchEvent(new Event('change'));
const pending = manager.download();
assert.equal(manager.busy, true);
assert.equal(elements.get('bootstrap-enter').disabled, true);
await pending;
assert.equal(manager.busy, false);
assert.equal(elements.get('bootstrap-enter').disabled, false);
assert.equal(downloaded.length, 3, 'duplicate resources must not be stored twice');
assert.equal(elements.get('offline-cache-toggle').checked, true);
assert.equal(elements.get('offline-cache-refresh').hidden, false);
assert.match(elements.get('offline-cache-status').textContent, /3 Dateien sind offline verfügbar/);
assert.ok(await store.match('/image.png'));
await manager.disable();
assert.equal(await store.state(), null);
assert.equal(elements.get('offline-cache-toggle').checked, false);
assert.equal(elements.get('offline-cache-refresh').hidden, true);
});
test('failed refresh preserves the old snapshot and download can be cancelled with the switch', async () => {
const { context, manager, store, elements } = await managerHarness({ saved: true });
context.navigator.serviceWorker.getRegistration = async () => ({ active: { scriptURL: `${origin}/offline-worker.js` } });
context.navigator.serviceWorker.register = async () => { assert.fail('offline restart must reuse the existing worker'); };
await manager.initialize();
assert.equal(elements.get('offline-cache-toggle').checked, true);
const fetchResource = context.fetch;
context.fetch = async (url, options) => url.endsWith('/image.png') ? new Response('missing', { status: 404 }) : fetchResource(url, options);
await assert.rejects(manager.download(), /Download fehlgeschlagen/);
assert.equal(await (await store.match('/')).text(), 'old');
assert.match(elements.get('offline-cache-status').textContent, /bisherige Offline-Stand/);
let started;
const downloading = new Promise(resolve => { started = resolve; });
context.fetch = (url, options) => new Promise((resolve, reject) => {
options.signal.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')), { once: true });
started();
});
const pending = manager.download();
const rejected = assert.rejects(pending, { name: 'AbortError' });
await downloading;
await manager.disable();
await rejected;
assert.equal(manager.busy, false);
assert.equal(await store.state(), null);
});
test('offline UI handles unsupported browsers and malformed manifests without enabling an incomplete snapshot', async () => {
const unsupported = await managerHarness({ supported: false });
await unsupported.manager.initialize();
assert.equal(unsupported.elements.get('offline-cache-toggle').disabled, true);
assert.match(unsupported.elements.get('offline-cache-status').textContent, /nicht verfügbar/);
const setup = await managerHarness();
await setup.manager.initialize();
setup.context.fetch = async () => Response.json({ resources: [] });
await assert.rejects(setup.manager.download(), /Ungültige Offline/);
assert.equal(await setup.store.state(), null);
setup.context.fetch = async () => new Response('error', { status: 500 });
await assert.rejects(setup.manager.download(), /Liste der Offline-Dateien/);
assert.equal(setup.elements.get('offline-cache-toggle').checked, false);
});
for (const filesystem of [false, true]) {
test(`app and selected routes are independent in ${filesystem ? 'OPFS' : 'CacheStorage'}, including API cache busters`, async () => {
const { store } = await loadStore(filesystem);
const app = await store.create();
await store.put(app, '/', new Response('app'));
await store.commit(app);
for (const id of [1, 2]) {
const stage = await store.create();
await store.put(stage, `/api/routes/${id}`, Response.json({ id }));
await store.put(stage, `/api/routes/${id}/gpx`, new Response(`gpx${id}`));
await store.commit(stage, { id, name: `Route ${id}`, pois: [], points: [] });
}
assert.equal((await store.routes()).length, 2);
assert.deepEqual(await (await store.match('/api/routes/1?_=123')).json(), { id: 1 });
await store.clear();
assert.equal(await store.match('/'), null);
assert.equal(await (await store.match('/api/routes/2/gpx')).text(), 'gpx2');
await store.clearRoute(1);
assert.equal(await store.match('/api/routes/1'), null);
assert.equal((await store.routes()).length, 1);
assert.equal(await (await store.match('/api/routes/2/gpx')).text(), 'gpx2');
});
}
test('explicit route download stores only selected GPX/media and failed refresh keeps previous route', async () => {
const { context, manager, store } = await managerHarness();
const route = { id: 7, name: 'Seven', activatedAt: '2026-09-30', gpxUrl: '/api/routes/7/gpx', pois: [{ audioUrl: '/audio7', images: [{ url: '/picture7' }] }] };
const requested = [];
context.fetch = async url => {
requested.push(url);
if (url === '/api/routes/7') return Response.json(route);
return new Response(`bytes:${url}`);
};
await manager.initialize();
assert.equal((await store.routes()).length, 0);
await manager.saveRoute(route);
assert.deepEqual(requested, ['/api/routes/7', origin + '/api/routes/7/gpx', origin + '/audio7', origin + '/picture7']);
assert.equal(await store.state(), null);
assert.equal((await store.routes()).length, 1);
assert.equal(await (await store.match('/api/routes/7/gpx')).text(), 'bytes:' + origin + '/api/routes/7/gpx');
const previous = (await store.routes())[0].id;
context.fetch = async url => url === '/api/routes/7' ? Response.json(route) : new Response('full', { status: 507 });
await assert.rejects(manager.saveRoute(route), /Download fehlgeschlagen/);
assert.equal((await store.routes())[0].id, previous);
await manager.disable();
assert.equal((await store.routes()).length, 1);
await manager.removeRoute(route);
assert.equal((await store.routes()).length, 0);
});
test('old automatic all-route snapshot is removed without enabling a new app download', async () => {
const { context, manager, store } = await managerHarness({ saved: true });
const control = await context.caches.open('wegwichtel-offline-control-v1');
const state = await store.state();
delete state.appOnly;
await control.put('/__wegwichtel_offline_state__', Response.json(state));
await manager.initialize();
assert.equal(await store.state(), null);
assert.equal(await store.match('/'), null);
});
test('API starts from selected offline routes and an empty list for app-only storage without network requests', async () => {
const { context, store } = await loadStore();
context.navigator.onLine = false;
context.URLSearchParams = URLSearchParams;
context.Wegwichtel.Config = { apiBase: '/api', routeRadiusKm: 5 };
context.jQuery = { ajax() { throw new Error('Network must not be used'); } };
const app = await store.create();
await store.put(app, '/', new Response('shell'));
await store.commit(app);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/api-client.js'), 'utf8'), context);
const api = context.Wegwichtel.Api;
assert.equal((await api.health()).offline, true);
assert.equal((await api.routes(null)).routes.length, 0);
const route = { id: 7, name: 'Saved', pois: [] };
const stage = await store.create();
await store.put(stage, '/api/routes/7', Response.json(route));
await store.commit(stage, route);
assert.equal((await api.routes(null)).routes[0].name, 'Saved');
assert.deepEqual(await api.route(7), route);
context.navigator.onLine = true;
context.jQuery.ajax = () => Promise.reject({ status: 0 });
assert.equal((await api.routes(null)).offline, true);
context.jQuery.ajax = () => Promise.reject({ status: 403 });
await assert.rejects(api.routes(null), error => error.status === 403);
});
test('storage gauge uses browser estimate, separates Local Storage and reports quota failures', async () => {
const elements = new Map();
const panel = { querySelector(selector) {
if (!elements.has(selector)) elements.set(selector, {});
return elements.get(selector);
} };
const context = {
document: { querySelectorAll: () => [panel] },
navigator: { storage: { estimate: async () => ({ usage: 95 * 1024 * 1024, quota: 100 * 1024 * 1024 }) } },
localStorage: { length: 1, key: () => 'image', getItem: () => 'x'.repeat(2400000) }
};
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/storage-usage.js'), 'utf8'), context);
const usage = context.Wegwichtel.StorageUsage;
await usage.refresh();
assert.equal(elements.get('[data-storage-site-meter]').value, 95 * 1024 * 1024);
assert.match(elements.get('[data-storage-site-text]').textContent, /95 %.*5 MiB verfügbar.*fast voll/);
assert.match(elements.get('[data-storage-local-text]').textContent, /Richtwert.*tatsächliche Grenze/);
usage.reportError({ cause: { name: 'QuotaExceededError' } });
await usage.refresh();
assert.match(elements.get('[data-storage-warning]').textContent, /Speicher voll/);
context.navigator.storage.estimate = async () => { throw new Error('Unavailable'); };
await usage.refresh();
assert.equal(elements.get('[data-storage-site-meter]').hidden, true);
});