// Run: node --experimental-vm-modules scripts/test_webapp.mjs import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import vm from 'node:vm'; const listeners = new Map(); const stores = new Map(); const key = (request) => new URL(typeof request === 'string' ? request : request.url, 'https://aoede.test').href; const caches = { async open(name) { if (!stores.has(name)) stores.set(name, new Map()); const store = stores.get(name); return { async put(request, response) { store.set(key(request), response.clone()); }, async match(request) { return store.get(key(request))?.clone(); }, async delete(request) { return store.delete(key(request)); }, async keys() { return [...store.keys()].map(url => ({ url })); } }; }, async keys() { return [...stores.keys()]; }, async delete(name) { return stores.delete(name); }, async match(request) { for (const name of stores.keys()) { const response = await (await this.open(name)).match(request); if (response) return response; } } }; let fetcher = async () => new Response('online'); vm.runInNewContext(await readFile(new URL('../Aoede/assets/sw.js', import.meta.url), 'utf8'), { self: { location: { origin: 'https://aoede.test' }, addEventListener: (name, callback) => listeners.set(name, callback), skipWaiting() {}, clients: { claim() {} } }, caches, fetch: (request) => fetcher(request), URL, Response, Date, Promise }); async function event(name, request) { const waits = []; let response; listeners.get(name)({ request, waitUntil(promise) { waits.push(promise); }, respondWith(promise) { response = promise; } }); const value = await response; await Promise.all(waits); return value; } const request = (path, mode = 'cors', method = 'GET') => ({ url: `https://aoede.test${path}`, mode, method, headers: new Headers({ accept: 'text/html' }) }); const redirected = (path = '/login') => { const response = new Response('login page'); Object.defineProperty(response, 'redirected', { value: true }); Object.defineProperty(response, 'url', { value: `https://aoede.test${path}` }); return response; }; fetcher = async (url) => url === '/portal' ? redirected() : new Response('asset'); await event('install'); assert.equal(await caches.match('/portal'), undefined); // Pre-login precaching cannot become an offline login shell. assert.ok(await caches.match('/assets/js/modules/about.js')); // The About dialog also opens offline before its first use. const current = [...stores.keys()][0]; await caches.open('another-app-cache'); await caches.open('aoede-shell-old'); await event('activate'); assert.ok(stores.has('another-app-cache')); assert.ok(!stores.has('aoede-shell-old')); fetcher = async () => new Response('music shell'); await event('fetch', request('/portal', 'navigate')); assert.equal(await (await caches.match('/portal')).text(), 'music shell'); fetcher = async () => redirected('/portal'); await event('fetch', request('/', 'navigate')); assert.ok(await caches.match('/portal')); // A normal / -> /portal redirect must preserve the offline shell. await (await caches.open(current)).put('/portal', new Response('music shell')); fetcher = async () => { throw new TypeError('offline'); }; assert.equal(await (await event('fetch', request('/', 'navigate'))).text(), 'music shell'); for (const path of ['/assets/css/theme_glass.css', '/assets/css/theme_vitrine.css', '/assets/js/modules/appearance.js']) { assert.equal(await (await event('fetch', request(`${path}?v=new-release`))).text(), 'asset'); } await assert.rejects(event('fetch', request('/assets/not-installed.js?v=new-release')), /offline/); fetcher = async () => new Response('fresh version'); assert.equal(await (await event('fetch', request('/assets/css/theme_glass.css?v=online'))).text(), 'fresh version'); fetcher = async () => { throw new TypeError('offline'); }; assert.equal(await (await event('fetch', request('/assets/css/theme_glass.css?v=online'))).text(), 'fresh version'); for (const path of ['/api/playlists', '/admin/api/system-stats']) { const response = await event('fetch', request(path)); assert.equal(response.status, 503); assert.equal((await response.json()).offline, true); } const admin = await event('fetch', request('/admin/system', 'navigate')); assert.equal(admin.status, 503); assert.ok(!(await admin.text()).includes('music shell')); assert.equal(await event('fetch', request('/user/settings')), undefined); // HTMX handles its actual connection failure. assert.equal(await event('fetch', request('/user/appearance')), undefined); // Private preference never enters shell caching. fetcher = async () => redirected(); await event('fetch', request('/portal', 'navigate')); assert.equal(await caches.match('/portal'), undefined); await (await caches.open(current)).put('/portal', new Response('cached user')); await event('fetch', request('/logout', 'navigate')); assert.equal(await caches.match('/portal'), undefined); // Execute the real router: imported settings/admin pages participate in Back history. const history = []; const handlers = new Map(); const bodyHandlers = new Map(); let settingsMounts = 0; let navigation; let homeWrapped = { year: 2026, enabled: true, visible: true }; let pendingRooms = null; let pendingDiscovery, pendingArtist, pendingProfile; const discoveryCalls = []; let navigationRevision = 0; let sidebarCloses = 0; let fetchView = async () => ({ redirected: true, url: 'https://aoede.test/login' }); const homeCalls = []; const retryButton = {}; class Section { html = ''; writes = 0; set innerHTML(value) { this.html = value; this.writes++; } get innerHTML() { return this.html; } querySelectorAll() { return []; } } class ViewContainer extends Section { style = {}; attributes = new Map(); sections = new Map(); root = null; set innerHTML(value) { super.innerHTML = value; this.root = value.includes('data-home-root') ? {} : null; this.sections = new Map([...value.matchAll(/data-home-section="([\w-]+)"/g)].map(match => [match[1], new Section()])); } get innerHTML() { return this.html.replace(/(
]*>)(<\/div>)/g, (_, open, name, close) => open + this.sections.get(name).innerHTML + close); } get children() { return this.html ? [{}] : []; } querySelector(selector) { if (selector === '[data-home-root]') return this.root; const name = selector.match(/^\[data-home-section="([\w-]+)"\]$/)?.[1]; return name ? this.sections.get(name) || null : retryButton; } setAttribute(name, value) { this.attributes.set(name, value); } removeAttribute(name) { this.attributes.delete(name); } } const container = new ViewContainer(); const document = { body: { addEventListener(name, handler) { bodyHandlers.set(name, handler); } }, getElementById: () => container }; const window = { location: { pathname: '/admin/system', assign(url) { navigation = url; } }, history: { state: null, replaceState(value) { this.state = value; history.push(['replace', value]); }, pushState(value) { this.state = value; history.push(['push', value]); } }, addEventListener(name, handler) { handlers.set(name, handler); } }; const context = vm.createContext({ window, document, console, URL, AbortController, setTimeout, clearTimeout, navigator: { onLine: true }, requestAnimationFrame(callback) { callback(); }, localStorage: { getItem(key) { return key === 'aoede.replaygain.enabled' ? '1' : '4'; } }, fetch: (...args) => fetchView(...args) }); const module = new vm.SourceTextModule(await readFile(new URL('../Aoede/assets/js/modules/views.js', import.meta.url), 'utf8'), { context }); await module.link(name => { const values = name === './state.js' ? { isSidebarOpen: true, toggleSidebar() { sidebarCloses++; }, setCurrentViewName() { navigationRevision++; }, viewGuard() { const revision = navigationRevision; return () => revision === navigationRevision; }, setCurrentViewParam() {}, setCurrentViewList() {}, recentPlaylists: [], recentMixes: [], recentRadios: [] } : name === './ui.js' ? { initSettingsTabs() { settingsMounts++; }, escHtml: String, t: (_, fallback) => fallback, getGreeting: () => 'Hello', homeTabsBar: () => '', refreshIcons() {} } : name === './api.js' ? { fetchRecommendations: async () => { throw Error('Feed unavailable'); }, fetchMixes: async () => [], fetchWrapped: async (year, options) => { homeCalls.push(options); return homeWrapped; }, fetchDiscoveryFeed: (...args) => { discoveryCalls.push(args); return pendingDiscovery; }, fetchArtist: () => pendingArtist, fetchUserProfile: () => pendingProfile } : name === './party.js' ? { fetchRooms: () => pendingRooms || Promise.resolve([]), renderHomeShelf: () => '
Parties
' } : name === './offline_store.js' ? { saveLibrarySnapshot() {}, getLibrarySnapshot: async () => ({ wrapped: { year: 2025 } }) } : name === './appearance.js' ? { syncThemeControls() {} } : name === './system_monitor.js' ? { initSystemMonitor() {} } : name === './admin_downloads.js' ? { initDownloadManager() {} } : {}; return new vm.SyntheticModule(Object.keys(values), function () { for (const [key, value] of Object.entries(values)) this.setExport(key, value); }, { context }); }); await module.evaluate(); module.namespace.initSpaHistory(); assert.equal(history[0][1].aoedeView, 'system_monitor'); await module.namespace.loadView('settings_user'); assert.equal(history.at(-1)[1].aoedeView, 'settings_user'); const count = history.length; await handlers.get('popstate')({ state: { aoedeView: 'system_monitor' } }); await new Promise(setImmediate); assert.equal(history.length, count); await module.namespace.loadView('settings_admin'); assert.equal(navigation, '/admin/'); const replaygain = { checked: false }; const savePrefs = { disabled: false }; const prefElements = { '#pref-replaygain': replaygain, '#pref-crossfade-chips': { querySelectorAll: () => [] }, '#pref-save-btn': savePrefs }; const settings = { querySelector: selector => prefElements[selector], querySelectorAll: () => [] }; bodyHandlers.get('htmx:afterSwap')({ target: { id: 'view-container', querySelector: () => settings } }); assert.equal(settingsMounts, 1); assert.equal(replaygain.checked, true); // Uploaded avatar/cookies replace settings; playback controls must be initialized again. assert.equal(savePrefs.disabled, true); console.log('PASS: proxy-independent navigation history, API offline errors, safe shell caching and logout invalidation.'); const initialWrites = container.writes; await module.namespace.renderHome(container); assert.equal(container.writes, initialWrites + 1); // One shell, not a full replacement for every feed response. const homeRoot = container.root; assert.equal(homeCalls[0].summary, false); // Availability must never depend on generating an annual report. assert.match(container.innerHTML, /href="\/wrapped\/story\/2026"/); assert.ok(container.innerHTML.indexOf('Watch Wrapped') < container.innerHTML.indexOf('quick-picks-grid')); assert.ok(container.innerHTML.indexOf('Watch Wrapped') < container.innerHTML.indexOf('Parties')); assert.doesNotMatch(container.innerHTML, /0 seconds listened/); for (const unavailable of [null, { enabled: false }, { visible: false }, { error: 'Unavailable' }]) { homeWrapped = unavailable; await module.namespace.renderHome(container); assert.equal(container.root, homeRoot); assert.equal(container.writes, initialWrites + 1); assert.doesNotMatch(container.innerHTML, /Watch Wrapped/); // Do not revive the stale cached enabled state online. } console.log('PASS: Wrapped availability survives unrelated feed errors, appears above quick access, and respects enable/visibility changes.'); let finishRooms, finishSettings; pendingRooms = new Promise(resolve => { finishRooms = resolve; }); homeWrapped = { year: 2026, enabled: true }; fetchView = () => new Promise(resolve => { finishSettings = resolve; }); const staleSettings = module.namespace.loadView('settings_user'); window.innerWidth = 360; const fastHome = module.namespace.loadView('home'); assert.equal(sidebarCloses, 1); // Mobile navigation closes the drawer before waiting for a feed. assert.match(container.innerHTML, /hero-greeting/); // No feed/party response is needed for the first paint. await new Promise(setImmediate); assert.match(container.innerHTML, /Watch Wrapped/); // A pending party federation cannot hide other shelves. const wrappedSection = container.sections.get('wrapped'); const wrappedWrites = wrappedSection.writes; await module.namespace.renderHome(container, () => false); assert.equal(container.sections.get('wrapped'), wrappedSection); assert.equal(wrappedSection.writes, wrappedWrites); assert.notEqual(container.style.opacity, '0'); finishSettings({ ok: false, status: 503 }); await staleSettings; assert.match(container.innerHTML, /hero-greeting/); // A late failure cannot replace the new Home. fetchView = async () => ({ ok: false, status: 503 }); await module.namespace.loadView('settings_user'); assert.match(container.innerHTML, /\/user\/settings.*HTTP 503/); assert.doesNotMatch(container.innerHTML, /Open directly/); assert.equal(container.attributes.has('aria-busy'), false); // Navigating away must not leave the next screen marked busy. fetchView = async () => ({ redirected: true, url: 'https://aoede.test/login' }); await retryButton.onclick(); assert.equal(navigation, 'https://aoede.test/login'); const errorHtml = container.innerHTML; finishRooms([]); await fastHome; assert.equal(container.innerHTML, errorHtml); // Late Home feeds cannot replace settings either. pendingRooms = null; console.log('PASS: immediate Home, independent shelves, stale navigation protection and actionable page retry.'); context.DOMParser = class { parseFromString() { return { getElementById: () => ({ childNodes: ['Help content'] }), querySelectorAll: () => [{ getAttribute: () => 'https://unpkg.com/lucide@0.468.0' }] }; } }; container.replaceChildren = (...nodes) => { container.innerHTML = nodes.join(''); }; fetchView = async () => ({ ok: true, text: async () => 'Old server page with CDN scripts' }); await module.namespace.loadView('help'); assert.equal(container.innerHTML, 'Help content'); // Imported HTML must not load any scripts from the old server's head. for (const view of ['discovery', 'artist', 'profile']) { let finish; const response = new Promise(resolve => { finish = resolve; }); if (view === 'discovery') pendingDiscovery = response; if (view === 'artist') pendingArtist = response; if (view === 'profile') pendingProfile = response; const pendingView = module.namespace.loadView(view, 'Test'); await module.namespace.loadView('help'); const latestHtml = container.innerHTML; finish(null); // Even an empty/error response must not replace the next destination. await pendingView; assert.equal(container.innerHTML, latestHtml, `Stale ${view} must not overwrite navigation`); } const stateModule = new vm.SourceTextModule(await readFile(new URL('../Aoede/assets/js/modules/state.js', import.meta.url), 'utf8'), { context }); await stateModule.link(() => { throw Error('State must remain dependency free'); }); await stateModule.evaluate(); const stillCurrent = stateModule.namespace.viewGuard(); assert.equal(stillCurrent(), true); stateModule.namespace.setCurrentViewName('home'); assert.equal(stillCurrent(), false); // Includes two successive visits to the same route. console.log('PASS: shared navigation guards discard late feeds, artists and profiles.'); // Categories must fetch beyond the initial All page and ignore out-of-order replies. const feed = new ViewContainer(); const caption = {}; const chips = ['all', 'trending', 'gems'].map(() => ({ classList: { toggle() {} }, attributes: new Map(), setAttribute(name, value) { this.attributes.set(name, value); } })); document.querySelector = selector => selector === '.discovery-feed' ? feed : caption; document.querySelectorAll = selector => selector.includes('filter-chips') ? chips : []; context.btoa = value => Buffer.from(value).toString('base64'); let finishTrending; pendingDiscovery = new Promise(resolve => { finishTrending = resolve; }); const oldTrending = module.namespace.discoveryFilterCategory('trending', chips[1]); assert.deepEqual(discoveryCalls.at(-1), [30, 'trending']); assert.equal(feed.attributes.get('aria-busy'), 'true'); pendingDiscovery = Promise.resolve({ items: [] }); await module.namespace.discoveryFilterCategory('gems', chips[2]); assert.match(feed.innerHTML, /No recommendations in this category/); assert.equal(chips[2].attributes.get('aria-pressed'), 'true'); assert.equal(chips[1].attributes.get('aria-pressed'), 'false'); assert.equal(feed.attributes.has('aria-busy'), false); finishTrending({ items: [{ title: 'Late hit' }] }); await oldTrending; assert.doesNotMatch(feed.innerHTML, /Late hit/); pendingDiscovery = Promise.resolve({ items: [{ title: 'Current hit', reason: 'Taste match' }] }); await module.namespace.discoveryFilterCategory('trending', chips[1]); assert.match(feed.innerHTML, /Current hit/); assert.match(feed.innerHTML, /Taste match/); pendingDiscovery = Promise.reject(Error('Unavailable')); await module.namespace.discoveryFilterCategory('gems', chips[2]); assert.match(feed.innerHTML, /role="alert"/); assert.equal(feed.attributes.has('aria-busy'), false); console.log('PASS: Discovery fetches each category, preserves explanations, reports errors and ignores stale replies.'); let syncFetches = 0, finishSync; context.fetch = () => { syncFetches++; return new Promise(resolve => { finishSync = resolve; }); }; const syncModule = new vm.SourceTextModule(await readFile(new URL('../Aoede/assets/js/modules/sync.js', import.meta.url), 'utf8'), { context }); await syncModule.link(name => { if (name === './state.js') return stateModule; const values = name === './offline_store.js' ? { flushPendingActions: async () => {} } : name === './appearance.js' ? { syncTheme() {} } : {}; return new vm.SyntheticModule(Object.keys(values), function () { for (const [key, value] of Object.entries(values)) this.setExport(key, value); }, { context }); }); await syncModule.evaluate(); const firstSync = syncModule.namespace.checkSyncState(); await new Promise(setImmediate); await syncModule.namespace.checkSyncState(); assert.equal(syncFetches, 1); // pageshow, visibilitychange and polling share the pending request. finishSync({ ok: true, json: async () => ({ version: 1 }) }); await firstSync; document.visibilityState = 'hidden'; await syncModule.namespace.checkSyncState(); assert.equal(syncFetches, 1); document.visibilityState = 'visible'; const nextSync = syncModule.namespace.checkSyncState(); await new Promise(setImmediate); assert.equal(syncFetches, 2); // Completion releases the guard for the next scheduled refresh. finishSync({ ok: true, json: async () => ({ version: 1 }) }); await nextSync; console.log('PASS: sync never overlaps or starts while hidden.'); // Deferred actions keep their account and order; a 401/503 cannot discard changes. const actions = [ { action_id: 1, type: 'toggle_favorite', username: 'bob', payload: { trackId: 1, liked: true } }, { action_id: 2, type: 'toggle_favorite', payload: { trackId: 2, liked: true } }, { action_id: 3, type: 'toggle_favorite', username: 'alice', payload: { trackId: 3, liked: true } }, { action_id: 4, type: 'toggle_favorite', username: 'alice', payload: { trackId: 4, liked: true } } ]; const resultRequest = result => { const req = { result }; queueMicrotask(() => req.onsuccess?.()); return req; }; const db = { transaction() { return { objectStore() { return { getAll: () => resultRequest([...actions]), delete(id) { actions.splice(actions.findIndex(a => a.action_id === id), 1); return resultRequest(true); }, add(action) { actions.push(action); return resultRequest(5); } }; } }; } }; const sent = []; let status = 503; const offlineContext = vm.createContext({ window: { USER_DATA: { username: 'alice' }, indexedDB: { open: () => resultRequest(db) } }, navigator: { onLine: true }, console, Date, Math, fetch: async path => { sent.push(path); return { ok: status === 200, status }; } }); const offline = new vm.SourceTextModule(await readFile(new URL('../Aoede/assets/js/modules/offline_store.js', import.meta.url), 'utf8'), { context: offlineContext }); await offline.link(() => { throw new Error('Unexpected dependency'); }); await offline.evaluate(); await Promise.all([offline.namespace.flushPendingActions(), offline.namespace.flushPendingActions()]); assert.deepEqual(sent, ['/api/favorites/3']); assert.equal(actions.length, 4); status = 401; await offline.namespace.flushPendingActions(); assert.equal(actions.length, 4); status = 200; await offline.namespace.flushPendingActions(); assert.deepEqual(actions.map(a => a.action_id), [1, 2]); await offline.namespace.queueAction('toggle_favorite', { trackId: 5, liked: true }); assert.equal(actions.at(-1).username, 'alice'); console.log('PASS: account-bound deferred actions, single synchronization and retained changes on failed connections.'); // Starting a verification browser is single-flight across provider buttons; // failure must leave a usable retry, without enabling unavailable providers. const verifyButtons = ['tidal', 'qobuz', 'amazon'].map(provider => ({ dataset: { providerVerify: provider }, disabled: provider === 'amazon', addEventListener(_name, handler) { this.click = handler; } })); const browserStatus = {}; const browserFrame = {}; const modalFields = Object.fromEntries(['#auth-browser-title', '[data-auth-browser-countdown]', '[data-auth-browser-frame]', '[data-auth-browser-close]', '[data-auth-browser-stop]', '[data-auth-browser-check]'].map(selector => [selector, selector === '[data-auth-browser-frame]' ? browserFrame : { addEventListener() {}, focus() {} } ])); const browserModal = { dataset: {}, hidden: true, setAttribute() {}, querySelector: selector => modalFields[selector] }; const managerRoot = { dataset: {}, querySelector: selector => selector === '#download-manager-status' ? browserStatus : selector === '#auth-browser-modal' ? browserModal : null, querySelectorAll: selector => selector === '[data-provider-verify]' ? verifyButtons : [] }; let finishBrowserRequest, failBrowserRequest; const browserRequests = []; const browserContext = vm.createContext({ document: {}, console, fetch: (url, init) => { browserRequests.push({ url, init }); return new Promise((resolve, reject) => { finishBrowserRequest = resolve; failBrowserRequest = reject; }); } }); const managerModule = new vm.SourceTextModule(await readFile(new URL('../Aoede/assets/js/modules/admin_downloads.js', import.meta.url), 'utf8'), { context: browserContext }); await managerModule.link(() => new vm.SyntheticModule(['confirmDialog'], function () { this.setExport('confirmDialog', async () => true); }, { context: browserContext })); await managerModule.evaluate(); managerModule.namespace.initDownloadManager({ querySelector: () => managerRoot }); const firstBrowserStart = verifyButtons[0].click(); await verifyButtons[1].click(); assert.equal(browserRequests.length, 1); assert.ok(verifyButtons.every(button => button.disabled)); finishBrowserRequest(Response.json({ detail: 'Chromium could not start: insufficient free processes/threads.' }, { status: 503 })); await firstBrowserStart; assert.match(browserStatus.textContent, /insufficient free processes/); assert.deepEqual(verifyButtons.map(button => button.disabled), [false, false, true]); assert.equal(browserModal.hidden, true); const failedBrowserRetry = verifyButtons[1].click(); failBrowserRequest(new TypeError('Network unavailable')); await failedBrowserRetry; assert.deepEqual(verifyButtons.map(button => button.disabled), [false, false, true]); const successfulBrowserRetry = verifyButtons[1].click(); finishBrowserRequest(Response.json({ status: 'ready', novnc_url: '/admin/auth-browser/vnc.html', browser_session: { remaining_seconds: 600 } }, { status: 202 })); await successfulBrowserRetry; assert.equal(browserRequests.length, 3); assert.equal(browserModal.hidden, false); assert.equal(browserModal.dataset.provider, 'qobuz'); assert.equal(browserFrame.src, '/admin/auth-browser/vnc.html'); assert.deepEqual(verifyButtons.map(button => button.disabled), [false, false, true]); console.log('PASS: verification startup blocks duplicate requests, preserves unavailable providers and recovers after resource/network failures.'); // Execute the real initial-route selection, including server-rendered deep links. const entry = await readFile(new URL('../Aoede/assets/js/main.js', import.meta.url), 'utf8'); const initialRoute = entry.slice(entry.indexOf(' const restoredSession ='), entry.indexOf(' // Initialize Lucide icons')); for (const native of [false, true]) for (const pathname of ['/wrapped/2026', '/wrapped', '/portal']) { const loads = []; const routeContext = vm.createContext({ window: { AOEDE_ANDROID: native, location: { pathname }, AOEDE_INITIAL_VIEW: 'wrapped', AOEDE_INITIAL_PARAM: 2026 }, navigator: { onLine: true }, player: { restorePlaybackSession: async () => ({ view: 'playlist', param: 123 }) }, views: { loadView: (...args) => loads.push(args) }, console }); await vm.runInContext(`(async () => {${initialRoute}})()`, routeContext); assert.equal(loads.length, 1); assert.equal(loads[0][0], 'wrapped'); assert.equal(loads[0][1], 2026); } console.log('PASS: Wrapped summary deep links start the actual initial view and override a restored playback view.'); const homeExitLoads = []; const homeExitUrls = []; await vm.runInNewContext(`(async () => {${initialRoute}})()`, { window: { location: { pathname: '/portal', search: '', hash: '#home' }, AOEDE_INITIAL_VIEW: 'home', history: { state: null, replaceState: (_state, _title, url) => homeExitUrls.push(url) } }, navigator: { onLine: true }, player: { restorePlaybackSession: async () => ({ view: 'wrapped', param: 2026 }) }, views: { loadView: view => homeExitLoads.push(view) }, console }); assert.deepEqual(homeExitLoads, ['home']); // Closing a story cannot reopen the summary from a saved playback session. assert.deepEqual(homeExitUrls, ['/portal']); // Consume the intent once so later launches can restore other views. const nativeLoads = []; await vm.runInNewContext(`(async () => {${initialRoute}})()`, { window: { AOEDE_ANDROID: true, location: { pathname: '/portal' }, AOEDE_INITIAL_VIEW: 'home' }, navigator: { onLine: true }, player: { restorePlaybackSession: async () => ({ view: 'system_monitor' }) }, views: { loadView: view => nativeLoads.push(view) }, console }); assert.deepEqual(nativeLoads, ['home']); // Native launch restores music, without reopening transient admin/settings pages. const chosenViewLoads = []; await vm.runInNewContext(`(async () => {${initialRoute}})()`, { window: { location: { pathname: '/portal' }, AOEDE_INITIAL_VIEW: 'home' }, navigator: { onLine: true }, player: { restorePlaybackSession: async () => ({ view: 'wrapped', param: 2026 }) }, views: { navigationVersion: 1, loadView: view => chosenViewLoads.push(view) }, console }); assert.deepEqual(chosenViewLoads, []); // A late restore must preserve the user's newer navigation. // Timeouts must cover response bodies as well as headers, preserving the local queue fallback. let queueTimer, queueSignal, queueClears = 0, queueFetches = 0; const queueContext = vm.createContext({ window: {}, navigator: { onLine: true }, console, AbortController, setTimeout(callback, delay) { assert.equal(delay, 2500); queueTimer = callback; return 1; }, clearTimeout() { queueClears++; }, fetch: async (_url, options) => { queueFetches++; queueSignal = options.signal; return { ok: true, json: () => new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(new DOMException('Timed out', 'AbortError')))) }; } }); const queueApi = new vm.SourceTextModule(await readFile(new URL('../Aoede/assets/js/modules/api.js', import.meta.url), 'utf8'), { context: queueContext }); await queueApi.link(name => new vm.SyntheticModule(name === './state.js' ? ['API'] : [], function () { if (name === './state.js') this.setExport('API', '/api'); }, { context: queueContext })); await queueApi.evaluate(); const stalledQueue = queueApi.namespace.fetchPlaybackQueueState(); await new Promise(setImmediate); queueTimer(); assert.equal(await stalledQueue, null); assert.equal(queueSignal.aborted, true); assert.equal(queueClears, 1); queueContext.window.AOEDE_NATIVE_OFFLINE = true; assert.equal(await queueApi.namespace.fetchPlaybackQueueState(), null); assert.equal(queueFetches, 1); console.log('PASS: bounded queue restoration including a stalled JSON body and immediate offline fallback.');