Ctrl K
ComunidadGuías…

scripts/test_android.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Run: node --experimental-vm-modules scripts/test_android.mjs
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';

const shim = await readFile(new URL('../android/app/src/main/assets/media-session.js', import.meta.url), 'utf8');
function page({ frame = false, early = false, native = true, audioContext = null, captureStream = null, imageClass = null, canvas = null, objectUrls = null } = {}) {
  const messages = [];
  const nativeActions = new Map();
  const clicks = [];
  const elements = new Map(), observers = [];
  let inParty = false;
  class Media extends EventTarget {
    paused = true;
    ended = false;
    src = 'https://example.com/song';
    currentSrc = this.src;
    currentTime = 5;
    duration = 100;
    playbackRate = 1;
    readyState = 4;
    volume = 1;
    muted = false;
    plays = 0;
    play() { this.plays++; this.paused = false; this.dispatchEvent(new Event('play')); return Promise.resolve(); }
    pause() { this.paused = true; this.dispatchEvent(new Event('pause')); }
  }
  class Session {
    _metadata = { title: 'Song', artist: 'Artist', artwork: [{ src: '/cover.png' }] };
    _state = 'none';
    get metadata() { return this._metadata; }
    set metadata(value) { this._metadata = value; }
    get playbackState() { return this._state; }
    set playbackState(value) { this._state = value; }
    setActionHandler(action, callback) { nativeActions.set(action, callback); }
    setPositionState(value) { this.position = value; }
  }
  const audio = new Media();
  const preview = new Media();
  const window = new EventTarget();
  if (audioContext) window.AudioContext = audioContext;
  if (captureStream) audio.captureStream = captureStream;
  window.top = frame ? {} : window;
  window.AoedeMedia = { postMessage(value) { messages.push(JSON.parse(value)); } };
  const document = new EventTarget();
  document.documentElement = early ? null : {};
  document.getElementById = (id) => elements.get(id) || (id === 'audio-player' ? audio : null);
  document.querySelector = (selector) => selector === '.player-footer.party-player' ? (inParty ? {} : null) : selector === 'audio' ? audio : { click() { clicks.push(selector); } };
  document.querySelectorAll = () => [audio, preview];
  document.createElement = () => canvas;
  const navigator = native ? { mediaSession: new Session() } : {};
  vm.runInNewContext(shim, { window, document, navigator, HTMLMediaElement: Media,
    MutationObserver: class { constructor(callback) { observers.push(callback); } observe(root) { assert.ok(root); } },
    location: { href: 'https://example.com/portal' }, URL: objectUrls || URL, Image: imageClass, Promise, Event, DOMException, console });
  const state = () => messages.filter((message) => message.type === 'state').at(-1);
  return { window, document, navigator, audio, preview, messages, nativeActions, state, clicks, elements,
    mutate() { observers.forEach(callback => callback()); }, party(value) { inParty = value; } };
}
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };

// Offline covers use the downloaded blob and bounded inline artwork, without a new HTTP fetch.
{
  const track = { db_id: 8, title: 'Offline song', thumbnail: 'https://example.com/offline-cover.png' };
  const inline = 'data:image/jpeg;base64,' + 'a'.repeat(5000);
  const covers = new Map([['player-cover', {}], ['fs-cover', {}]]);
  const revoked = [];
  const p = page({
    imageClass: class { naturalWidth = 512; naturalHeight = 512; set src(value) { assert.equal(value, 'blob:downloaded-cover'); queueMicrotask(() => this.onload()); } },
    objectUrls: Object.assign(class extends URL {}, { createObjectURL: () => 'blob:downloaded-cover', revokeObjectURL: value => revoked.push(value) }),
    canvas: { getContext: () => ({ drawImage() {} }), toDataURL: () => inline }
  });
  for (const [id, cover] of covers) p.elements.set(id, cover);
  const context = vm.createContext({ window: p.window, navigator: p.navigator, document: p.document, console });
  const player = new vm.SourceTextModule(await readFile(new URL('../Aoede/assets/js/modules/player.js', import.meta.url), 'utf8') + '\nexport { updateMediaSessionMetadata };', { context });
  await player.link(name => {
    const values = name === './state.js' ? { currentTrack: track, audio: { duration: 0 } }
      : name === './offline_store.js' ? { getOfflineTrack: async id => { assert.equal(id, 8); return { artwork_blob: { size: 1000 } }; } } : {};
    return new vm.SyntheticModule(Object.keys(values), function () { for (const [key, value] of Object.entries(values)) this.setExport(key, value); }, { context });
  });
  await player.evaluate();
  player.namespace.updateMediaSessionMetadata(track);
  await new Promise(setImmediate);
  assert.equal(p.navigator.mediaSession.metadata.artwork[0].src, inline);
  assert.equal(p.navigator.mediaSession.metadata.artwork[0].type, 'image/jpeg');
  assert.equal(covers.get('player-cover').src, inline);
  assert.equal(covers.get('fs-cover').src, inline);
  assert.deepEqual(revoked, ['blob:downloaded-cover']);
  assert.equal(p.messages.filter(message => message.type === 'metadata').at(-1).artwork, inline);
  const remote = { db_id: 8, fed_instance_id: 2, title: 'Remote song', thumbnail: 'https://remote.example/art.png' };
  player.namespace.updateMediaSessionMetadata(remote);
  assert.equal(p.navigator.mediaSession.metadata.artwork[0].src, remote.thumbnail); // A remote ID cannot reuse a local cover.
  let finishOldCover;
  p.window.AoedeAndroid.withOfflineArtwork({ artwork: [] }, 9, () => new Promise(resolve => { finishOldCover = resolve; }));
  p.window.AoedeAndroid.withOfflineArtwork({ artwork: [] }, null);
  finishOldCover({ artwork_blob: { size: 1000 } });
  await new Promise(setImmediate);
  assert.equal(p.navigator.mediaSession.metadata.artwork[0].src, remote.thumbnail);
  assert.deepEqual(revoked, ['blob:downloaded-cover']); // A discarded lookup cannot allocate or publish another cover.
}

// The island reads real frequency bands from a disposable copy, never the audible output.
{
  const contexts = [], streams = [];
  let tone = [0, 0];
  class AudioContext {
    state = 'running'; sampleRate = 48000;
    constructor() { contexts.push(this); }
    createAnalyser() {
      return { frequencyBinCount: 512, getByteFrequencyData(data) {
        data.fill(0);
        if (tone[1]) data[Math.round(tone[0] / (48000 / 1024))] = tone[1];
      } };
    }
    createMediaStreamSource(stream) {
      assert.equal(stream, streams.at(-1));
      return { connect(node) { assert.ok(node.getByteFrequencyData); }, disconnect() {} };
    }
    createMediaElementSource() { assert.fail('Visuals must not reroute the playing audio'); }
    close() { this.state = 'closed'; return Promise.resolve(); }
    resume() { this.state = 'running'; return Promise.resolve(); }
  }
  const p = page({ audioContext: AudioContext, captureStream() {
    const track = { stopped: false, readyState: 'live', enabled: true, muted: false, stop() { this.stopped = true; } };
    const stream = { getTracks: () => [track], getAudioTracks: () => [track] };
    streams.push(stream); return stream;
  } });
  const levels = () => Array.from(p.window.__aoedeIslandLevels(true) || []);
  assert.deepEqual(levels(), []);
  assert.equal(contexts.length, 0); // Paused/off islands allocate nothing.
  p.window.__aoedeMediaCommand('play'); p.audio.dispatchEvent(new Event('playing')); await flush();
  assert.deepEqual(levels(), [0, 0, 0, 0, 0]); // Silence has no invented beat.
  tone = [140, 255];
  const bass = levels();
  assert.ok(bass[0] > 0 && bass.slice(1).every(n => n === 0));
  tone = [6000, 255];
  const treble = levels();
  assert.ok(treble[4] > 0 && treble.slice(0, 4).every(n => n === 0));
  p.audio.volume = .5;
  assert.ok(Math.abs(levels()[4] * 2 - treble[4]) <= .002);
  p.audio.muted = true;
  assert.deepEqual(levels(), [0, 0, 0, 0, 0]); p.audio.muted = false;
  assert.equal(contexts.length, 1);
  streams[0].getTracks()[0].muted = true;
  assert.equal(p.window.__aoedeIslandLevels(true), null); // Protected audio gets the requested animation fallback.
  streams[0].getTracks()[0].muted = false;
  const plays = p.audio.plays;
  p.audio.src = p.audio.currentSrc = 'https://example.com/second-song';
  levels();
  assert.equal(contexts[0].state, 'closed');
  assert.equal(streams[0].getTracks()[0].stopped, true);
  assert.equal(contexts.length, 1); // Disposal is asynchronous: wait before allocating the next graph.
  await new Promise(setImmediate); levels();
  assert.equal(contexts.length, 2); // Source swaps cannot keep sampling a finished track.
  p.window.__aoedeIslandLevels(false);
  assert.equal(contexts[1].state, 'closed');
  assert.equal(p.audio.paused, false);
  assert.equal(p.audio.plays, plays);
  assert.equal(p.audio.volume, .5);
  await new Promise(setImmediate); levels(); p.window.__aoedeMediaCommand('pause');
  assert.equal(contexts[2].state, 'closed');
  assert.deepEqual(levels(), []);
  const unavailable = page({ audioContext: AudioContext, captureStream() { throw new DOMException('No capture', 'SecurityError'); } });
  unavailable.window.__aoedeMediaCommand('play'); unavailable.audio.dispatchEvent(new Event('playing')); await flush();
  assert.equal(unavailable.window.__aoedeIslandLevels(true), null);
  assert.equal(unavailable.audio.paused, false); // Cross-origin/unsupported sources must keep playing.
  let captures = 0;
  const empty = page({ audioContext: AudioContext, captureStream() {
    captures++; return { getAudioTracks: () => [], getTracks: () => [] };
  } });
  empty.window.__aoedeMediaCommand('play'); empty.audio.dispatchEvent(new Event('playing')); await flush();
  for (let i = 0; i < 5; i++) assert.equal(empty.window.__aoedeIslandLevels(true), null);
  assert.equal(captures, 1); // Unsupported WebViews must not capture/dispose streams on every sample.
  let resumes = 0;
  class SuspendedContext extends AudioContext {
    state = 'suspended';
    resume() { resumes++; return new Promise(() => {}); }
  }
  const suspended = page({ audioContext: SuspendedContext, captureStream() {
    const track = { readyState: 'live', enabled: true, muted: false, stop() {} };
    const stream = { getTracks: () => [track], getAudioTracks: () => [track] };
    streams.push(stream); return stream;
  } });
  suspended.window.__aoedeMediaCommand('play'); suspended.audio.dispatchEvent(new Event('playing')); await flush();
  for (let i = 0; i < 5; i++) assert.equal(suspended.window.__aoedeIslandLevels(true), null);
  assert.equal(resumes, 1); assert.equal(suspended.audio.paused, false);
  suspended.window.__aoedeIslandLevels(false);
  assert.equal(contexts.at(-1).state, 'closed');
}

// Reordered native requests and deferred WebAudio close/resume must not recreate an obsolete graph.
{
  let captures = 0, closes = 0, stopped = 0, finishClose, rejectResume;
  const contexts = [];
  class DeferredContext {
    state = 'suspended'; sampleRate = 48000;
    constructor() { contexts.push(this); }
    createAnalyser() { return { frequencyBinCount: 512, getByteFrequencyData(data) { data.fill(0); } }; }
    createMediaStreamSource() { return { connect() {}, disconnect() { throw new Error('optional node cleanup failure'); } }; }
    resume() { return new Promise((_, reject) => { rejectResume = reject; }); }
    close() { closes++; return new Promise(resolve => { finishClose = () => { this.state = 'closed'; resolve(); }; }); }
  }
  const p = page({ audioContext: DeferredContext, captureStream() {
    captures++;
    const tracks = [{ readyState: 'live', enabled: true, muted: false, stop() { throw new Error('one track cleanup failed'); } },
      { readyState: 'live', enabled: true, muted: false, stop() { stopped++; } }];
    return { getTracks: () => tracks, getAudioTracks: () => tracks };
  } });
  p.window.__aoedeMediaCommand('play'); p.audio.dispatchEvent(new Event('playing')); await flush();
  const plays = p.audio.plays;
  assert.equal(p.window.__aoedeIslandLevels(true, 1), null);
  p.window.__aoedeIslandLevels(false, 2);
  for (let i = 0; i < 30; i++) {
    p.window.__aoedeIslandLevels(true, 1); // A queued request from before stop is stale.
    p.window.__aoedeIslandLevels(true, 3); // Even a fresh request must wait for disposal.
  }
  assert.equal(captures, 1); assert.equal(closes, 1); assert.equal(stopped,1);
  rejectResume(new Error('resume cancelled by close')); await new Promise(setImmediate);
  finishClose(); await new Promise(setImmediate);
  p.window.__aoedeIslandLevels(false, 4);
  p.window.__aoedeIslandLevels(true, 3);
  assert.equal(captures, 1); // Stop remains authoritative after the old promises settle.
  p.window.__aoedeIslandLevels(true, 5);
  assert.equal(captures, 2);
  p.window.__aoedeIslandLevels(false, 4);
  assert.equal(closes, 1); // A stale stop cannot dispose a newer graph.
  assert.equal(p.audio.plays, plays); assert.equal(p.audio.paused, false); assert.equal(p.audio.volume, 1);

  let failedCaptures = 0;
  class FailedClose extends DeferredContext { state = 'running'; close() { return Promise.reject(new Error('Close failed')); } }
  const failed = page({audioContext:FailedClose,captureStream(){
    failedCaptures++; const track={readyState:'live',enabled:true,muted:false,stop(){}};
    return {getTracks:()=>[track],getAudioTracks:()=>[track]};
  }});
  failed.window.__aoedeMediaCommand('play'); failed.audio.dispatchEvent(new Event('playing')); await flush();
  failed.window.__aoedeIslandLevels(true,1); failed.window.__aoedeIslandLevels(false,2);
  await new Promise(setImmediate);
  for(let i=3;i<20;i++)failed.window.__aoedeIslandLevels(true,i);
  failed.audio.src = failed.audio.currentSrc = 'https://example.com/new-source';
  failed.audio.dispatchEvent(new Event('play')); failed.audio.dispatchEvent(new Event('playing')); await flush();
  failed.window.__aoedeIslandLevels(true,20);
  assert.equal(failedCaptures,1); assert.equal(failed.audio.paused,false);
}

// Playback progress can recover a missed WebView "playing" event after buffering.
{
  const p = page();
  p.window.__aoedeMediaCommand('play'); p.audio.dispatchEvent(new Event('playing')); await flush();
  p.audio.readyState = 2; p.audio.dispatchEvent(new Event('waiting')); await flush();
  assert.equal(p.state().state, 'buffering');
  p.audio.readyState = 3; p.audio.currentTime++;
  p.audio.dispatchEvent(new Event('timeupdate')); await flush();
  assert.equal(p.state().state, 'playing');
  p.window.__aoedeMediaCommand('pause');
  p.audio.dispatchEvent(new Event('timeupdate')); await flush();
  assert.equal(p.state().state, 'paused');
}

// Island controls use the existing player buttons and retain its availability rules.
{
  const p = page();
  const makeButton = () => ({ disabled: false, classes: new Set(),
    classList: { contains(name) { return this.owner.classes.has(name); } },
    querySelector() { return null; }, click() { this.clicks = (this.clicks || 0) + 1; } });
  for (const id of ['fs-btn-favorite', 'btn-shuffle', 'btn-repeat']) {
    const button = makeButton(); button.classList.owner = button; p.elements.set(id, button);
  }
  p.window.toggleFavoriteFromPlayer = () => {};
  p.window.toggleShuffle = () => {};
  p.window.toggleRepeat = () => {};
  const controls = () => p.messages.filter(message => message.type === 'controls').at(-1);
  p.mutate();
  assert.equal(controls().canFavorite, true);
  for (const [action, id] of [['favorite', 'fs-btn-favorite'], ['shuffle', 'btn-shuffle'], ['repeat', 'btn-repeat']]) {
    p.window.__aoedeMediaCommand(action);
    assert.equal(p.elements.get(id).clicks, 1);
  }
  p.elements.get('fs-btn-favorite').disabled = true;
  p.elements.get('btn-repeat').querySelector = () => ({});
  p.mutate();
  assert.equal(controls().canFavorite, false);
  assert.equal(controls().repeat, 'one');
  p.window.__aoedeMediaCommand('favorite');
  assert.equal(p.elements.get('fs-btn-favorite').clicks, 1);
  p.party(true); p.mutate();
  assert.equal(controls().canShuffle, false);
  assert.equal(controls().canRepeat, false);
  p.window.__aoedeMediaCommand('shuffle');
  assert.equal(p.elements.get('btn-shuffle').clicks, 1);
  // A deferred open is idempotent and cannot start/resume music.
  p.window.__aoedeMediaCommand('openplayer');
  const panel = { opened: false, classList: { contains() { return panel.opened; } } };
  p.elements.set('fullscreen-player', panel);
  let opens = 0;
  p.window.toggleFullscreenPlayer = () => { opens++; panel.opened = !panel.opened; };
  p.window.showFullscreenCover = () => {};
  p.mutate(); p.window.__aoedeMediaCommand('openplayer');
  assert.equal(opens, 1);
  assert.equal(panel.opened, true);
  assert.equal(p.audio.plays, 0);
}

for (const native of [true, false]) {
  const p = page({ native });
  const session = p.navigator.mediaSession;
  assert.equal(p.state().state, 'none'); // Opening the app must not announce playback.
  session.setActionHandler('play', () => { p.audio.play(); session.playbackState = 'playing'; });
  session.setActionHandler('pause', () => { throw new Error('A party-wide pause must not run'); });
  p.window.__aoedeMediaCommand('play');
  await flush();
  assert.equal(p.state().state, 'buffering'); // The play promise/event is not yet audible playback.
  p.audio.dispatchEvent(new Event('playing'));
  await flush();
  assert.equal(p.state().state, 'playing');
  assert.equal(p.state().position, 5000);
  const count = p.messages.length;
  p.preview.pause();
  await flush();
  assert.equal(p.messages.length, count); // Previews cannot overwrite the main player's state.

  p.window.__aoedeMediaCommand('pause');
  await flush();
  assert.equal(p.state().state, 'paused');
  assert.equal(p.window.AoedeAndroid.isPaused, true);
  const plays = p.audio.plays;
  await assert.rejects(p.audio.play(), { name: 'NotAllowedError' });
  session.playbackState = 'playing'; // A later room update cannot undo local suspension.
  session.setPositionState({ position: 10, duration: 100 });
  await flush();
  assert.equal(p.audio.plays, plays);
  assert.equal(p.state().state, 'paused');
  if (native) {
    session.setActionHandler('seekto', ({ seekTime }) => { p.audio.currentTime = seekTime; });
    p.nativeActions.get('pause')();
    p.nativeActions.get('seekto')({ seekTime: 12 });
    await flush();
    assert.equal(p.audio.currentTime, 12);
    assert.equal(p.window.AoedeAndroid.isPaused, true); // Seeking must not resume a local/noisy pause.
    assert.equal(p.state().state, 'paused');
  }
  p.window.__aoedeMediaCommand('play');
  await flush();
  assert.equal(p.audio.plays, plays + 1);
  assert.equal(p.window.AoedeAndroid.isPaused, false);

  p.audio.dispatchEvent(new Event('playing'));
  await flush();
  p.audio.ended = true;
  p.audio.paused = true;
  p.audio.dispatchEvent(new Event('ended'));
  // App listener order can differ: its intent to continue must survive either order.
  session.playbackState = 'playing';
  await flush();
  assert.equal(p.state().state, 'buffering');
  session.playbackState = 'none';
  await flush();
  assert.equal(p.state().state, 'none');

  p.window.__aoedeMediaCommand('stop');
  p.audio.dispatchEvent(new Event('timeupdate'));
  await flush();
  assert.equal(p.state().state, 'none'); // Stop must not be resurrected by progress events.
  if (native) {
    assert.equal(session._state, 'none'); // Preserve the underlying Chromium MediaSession API.
    assert.ok(p.nativeActions.get('pause'));
  }
}

const iframe = page({ frame: true });
assert.equal(iframe.messages.length, 0);
assert.equal(iframe.window.AoedeAndroid, undefined);
const early = page({ early: true });
early.document.documentElement = {};
early.document.dispatchEvent(new Event('DOMContentLoaded'));
assert.equal(early.state().state, 'none');

// Exercise the actual web player: party snapshots and scheduled starts respect local suspension.
const timers = new Map();
let timerId = 0;
let starts = 0;
const audio = { paused: true, readyState: 4, currentTime: 0, duration: 100,
  pause() { this.paused = true; }, async play() { starts++; this.paused = false; } };
const track = { id: 'party-track', db_id: 3, stream_url: '/stream' };
const state = { audio, currentTrack: track, setIsPlaying() {} };
const window = { AoedeAndroid: { isPaused: false, resumeLocal() { this.isPaused = false; } } };
const context = vm.createContext({ window, navigator: { mediaSession: { playbackState: 'none' } }, console,
  clearTimeout(id) { timers.delete(id); }, setTimeout(callback) { const id = ++timerId; timers.set(id, callback); return id; } });
const player = new vm.SourceTextModule(await readFile(new URL('../Aoede/assets/js/modules/player.js', import.meta.url), 'utf8'), { context });
await player.link((name) => {
  const values = name === './state.js' ? state : name === './ui.js' ? { updatePlayButton() {} } : {};
  return new vm.SyntheticModule(Object.keys(values), function () {
    for (const [key, value] of Object.entries(values)) this.setExport(key, value);
  }, { context });
});
await player.evaluate();
let blockedStarts = 0;
player.namespace.setPartyController({ isActive: () => true, onAutoplayBlocked() { blockedStarts++; } });
const room = { current_track: track, current_item_id: 2, playback_status: 'playing', playback_position_ms: 2000 };
player.namespace.suspendLocalPlayback();
assert.equal(await player.namespace.syncPartyPlayback(room), false);
assert.equal(starts, 0);
player.namespace.resumeLocalPlayback();
assert.equal(await player.namespace.syncPartyPlayback(room), true);
assert.equal(starts, 1);
audio.paused = true;
await player.namespace.syncPartyPlayback({ ...room, playback_starts_at_ms: 3000, server_time_ms: 1000 });
const pending = [...timers.values()].at(-1);
assert.ok(pending);
player.namespace.suspendLocalPlayback();
pending();
assert.equal(starts, 1);
window.AoedeAndroid.isPaused = true;
player.namespace.resumeLocalPlayback();
assert.equal(await player.namespace.syncPartyPlayback(room), true);
assert.equal(starts, 2);
audio.paused = true;
audio.play = async () => { throw new DOMException('Autoplay blocked', 'NotAllowedError'); };
await player.namespace.syncPartyPlayback({ ...room, playback_starts_at_ms: 5000, server_time_ms: 1000 });
[...timers.values()].at(-1)();
await flush();
assert.equal(blockedStarts, 1); // A delayed play rejection is reported immediately, without waiting for another SSE event.
console.log('Android media bridge and local party interruption checks passed');