import { loadI18n } from '../test-support/i18n.js'; 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; await loadI18n(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 })), /Teil konnte nicht gespeichert werden/); await store.remove(failed.id); assert.equal(await (await store.match('/image.png')).text(), 'first'); assert.throws(() => store.key('https://outside.test/image.png'), /anderen Seiten/); 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; await loadI18n(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', 'info-offline-cache-toggle', 'info-offline-cache-status', 'info-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); } await loadI18n(context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') }); await loadI18n(context); 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); assert.equal(elements.get('info-offline-cache-toggle').checked, false); elements.get('info-offline-cache-toggle').checked = true; elements.get('info-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('info-offline-cache-toggle').checked, true); assert.equal(elements.get('info-offline-cache-toggle').attributes.get('aria-checked'), 'true'); assert.equal(elements.get('info-offline-cache-status').textContent, elements.get('offline-cache-status').textContent); assert.equal(elements.get('info-offline-cache-refresh').hidden, false); assert.equal(elements.get('offline-cache-refresh').hidden, false); assert.match(elements.get('offline-cache-status').textContent, /Wegwichtel ist für unterwegs bereit/); assert.ok(await store.match('/image.png')); elements.get('info-offline-cache-refresh').dispatchEvent(new Event('click')); const refreshTask = manager.download(); assert.equal(elements.get('offline-cache-refresh').disabled, true); assert.equal(elements.get('info-offline-cache-refresh').disabled, true); await refreshTask; assert.equal(downloaded.length, 6); assert.equal(elements.get('info-offline-cache-refresh').disabled, false); elements.get('offline-cache-toggle').checked = false; elements.get('offline-cache-toggle').dispatchEvent(new Event('change')); await new Promise(resolve => setImmediate(resolve)); assert.equal(await store.state(), null); assert.equal(elements.get('offline-cache-toggle').checked, false); assert.equal(elements.get('offline-cache-refresh').hidden, true); assert.equal(elements.get('info-offline-cache-toggle').checked, false); assert.equal(elements.get('info-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(), /Teil konnte nicht gespeichert werden/); assert.equal(await (await store.match('/')).text(), 'old'); assert.match(elements.get('offline-cache-status').textContent, /vorher gespeichert hast/); 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.equal(unsupported.elements.get('info-offline-cache-toggle').disabled, true); assert.match(unsupported.elements.get('offline-cache-status').textContent, /nicht für unterwegs speichern/); const setup = await managerHarness(); await setup.manager.initialize(); setup.context.fetch = async () => Response.json({ resources: [] }); await assert.rejects(setup.manager.download(), /nicht für unterwegs geladen/); assert.equal(await setup.store.state(), null); setup.context.fetch = async () => new Response('error', { status: 500 }); await assert.rejects(setup.manager.download(), /nicht für unterwegs geladen/); assert.equal(setup.elements.get('offline-cache-toggle').checked, false); assert.equal(setup.elements.get('info-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: `Strecke ${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), /Teil konnte nicht gespeichert werden/); 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); await loadI18n(context); 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) } }; await loadI18n(context); 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); const freeSpace = (5 * 1024 * 1024 / 1000000).toLocaleString('de', { maximumFractionDigits: 1 }); const siteText = elements.get('[data-storage-site-text]').textContent; assert.match(siteText, /95 %.*Fast voll/); assert.ok(siteText.includes(`${freeSpace} MB frei`), siteText); assert.match(elements.get('[data-storage-local-text]').textContent, /Richtwert.*wirklich hineinpasst/); usage.reportError({ cause: { name: 'QuotaExceededError' } }); await usage.refresh(); assert.match(elements.get('[data-storage-warning]').textContent, /Platz ist voll/); context.navigator.storage.estimate = async () => { throw new Error('Unavailable'); }; await usage.refresh(); assert.equal(elements.get('[data-storage-site-meter]').hidden, true); }); test('storage gauge uses the selected language with both localized and fallback number formatting', async () => { const source = await fs.readFile(path.join(root, 'public/js/storage-usage.js'), 'utf8'); for (const decimalSeparator of [',', '.']) { const label = {}; const calls = []; const context = { document: { querySelectorAll: selector => selector === '[data-storage-usage]' ? [{ querySelector: selector => selector === '[data-storage-site-text]' ? label : null }] : [] }, navigator: { storage: { estimate: async () => ({ usage: 95 * 1024 * 1024, quota: 100 * 1024 * 1024 }) } }, formatNumber(value, language, options) { calls.push({ value, language, options }); return value.toFixed(1).replace('.', decimalSeparator); } }; const i18n = await loadI18n(context); // Model a runner whose Intl data falls back to a dot, without changing host globals. vm.runInNewContext('Number.prototype.toLocaleString = function (language, options) { return formatNumber(Number(this), language, options); };', context); vm.runInNewContext(source, context); await context.Wegwichtel.StorageUsage.refresh(); assert.ok(label.textContent.includes(`5${decimalSeparator}2 MB frei`)); assert.equal(calls[0].language, 'de'); assert.equal(calls[0].options.maximumFractionDigits, 1); assert.equal(calls[0].value, 5 * 1024 * 1024 / 1000000); i18n.register('en', {}); i18n.setLanguage('en', null); await context.Wegwichtel.StorageUsage.refresh(); assert.equal(calls.at(-1).language, 'en'); } });