// 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: () => '' }
: 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.');