754 lines
28 KiB
JavaScript
Executable File
754 lines
28 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/* eslint-disable no-console */
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const cp = require('node:child_process');
|
|
const { performance } = require('node:perf_hooks');
|
|
const { pathToFileURL } = require('node:url');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const LIVE_MODE = process.argv.includes('--live');
|
|
|
|
const filesUnderTest = [
|
|
'src/utils.js',
|
|
'src/cache.js',
|
|
'src/player.js',
|
|
'src/downloads.js',
|
|
'src/lyrics.js',
|
|
'src/api.js',
|
|
'src/app.js',
|
|
];
|
|
|
|
const results = [];
|
|
|
|
function record(status, name, detail = '', ms = 0, mode = 'unit') {
|
|
results.push({ status, name, detail, ms, mode });
|
|
const icon = status === 'pass' ? '✅' : status === 'skip' ? '⏭️' : '❌';
|
|
const label = status.toUpperCase();
|
|
const time = ms ? ` (${ms.toFixed(1)}ms)` : '';
|
|
console.log(`${icon} ${label}: [${mode}] ${name}${time}${detail ? ` — ${detail}` : ''}`);
|
|
}
|
|
|
|
function assert(condition, message = 'Assertion failed') {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function stripImportsAndExports(code) {
|
|
return code
|
|
.replace(/^\s*import\s+[^;]+;\s*$/gm, '')
|
|
.replace(/export\s+class\s+/g, 'class ')
|
|
.replace(/export\s+function\s+/g, 'function ')
|
|
.replace(/export\s+const\s+/g, 'const ')
|
|
.replace(/export\s+let\s+/g, 'let ')
|
|
.replace(/export\s+default\s+/g, '');
|
|
}
|
|
|
|
function loadModule(relativePath, exportNames, context = {}) {
|
|
const filePath = path.join(ROOT, relativePath);
|
|
const code = fs.readFileSync(filePath, 'utf8');
|
|
const transformed = stripImportsAndExports(code);
|
|
|
|
const keys = Object.keys(context);
|
|
const values = Object.values(context);
|
|
|
|
const factory = new Function(...keys, `'use strict';\n${transformed}\nreturn { ${exportNames.join(', ')} };`);
|
|
return factory(...values);
|
|
}
|
|
|
|
function createMockResponse({ ok = true, status = 200, jsonData = null, blobData = null, headers = {} } = {}) {
|
|
return {
|
|
ok,
|
|
status,
|
|
headers: {
|
|
get(name) {
|
|
return headers[String(name).toLowerCase()] ?? null;
|
|
},
|
|
},
|
|
async json() {
|
|
return typeof jsonData === 'function' ? jsonData() : jsonData;
|
|
},
|
|
async blob() {
|
|
return blobData ?? new Blob(['blob']);
|
|
},
|
|
body: null,
|
|
};
|
|
}
|
|
|
|
async function runTest(name, fn, mode = 'unit') {
|
|
const started = performance.now();
|
|
try {
|
|
const maybeDetail = await fn();
|
|
const ended = performance.now();
|
|
record('pass', name, typeof maybeDetail === 'string' ? maybeDetail : '', ended - started, mode);
|
|
} catch (error) {
|
|
const ended = performance.now();
|
|
if (error && error.__skip__) {
|
|
record('skip', name, error.message || 'skipped', ended - started, mode);
|
|
} else {
|
|
record('fail', name, error?.stack || error?.message || String(error), ended - started, mode);
|
|
}
|
|
}
|
|
}
|
|
|
|
function skip(message) {
|
|
const err = new Error(message || 'skipped');
|
|
err.__skip__ = true;
|
|
throw err;
|
|
}
|
|
|
|
// ---------------------------
|
|
// Unit suites (fully mocked)
|
|
// ---------------------------
|
|
|
|
async function testSyntaxChecks() {
|
|
for (const file of filesUnderTest) {
|
|
const abs = path.join(ROOT, file);
|
|
cp.execFileSync(process.execPath, ['--check', abs], { stdio: 'pipe' });
|
|
}
|
|
return `${filesUnderTest.length} files`;
|
|
}
|
|
|
|
async function testUtils() {
|
|
const { formatDuration, formatYear, escapeHtml, getTrackArtists, sanitizeForFilename, buildTrackFilename, debounce } =
|
|
loadModule('src/utils.js', [
|
|
'formatDuration',
|
|
'formatYear',
|
|
'escapeHtml',
|
|
'getTrackArtists',
|
|
'sanitizeForFilename',
|
|
'buildTrackFilename',
|
|
'debounce',
|
|
]);
|
|
|
|
assert(formatDuration(65) === '1:05', 'formatDuration(65) should be 1:05');
|
|
assert(formatYear('2024-03-01') === '2024', 'formatYear should parse year');
|
|
|
|
const escaped = escapeHtml(`<a&'" >`);
|
|
assert(!escaped.includes('<') && escaped.includes('&'), 'escapeHtml should escape');
|
|
|
|
assert(getTrackArtists({ artist: { name: 'A' } }) === 'A', 'getTrackArtists artist object');
|
|
assert(getTrackArtists({ artists: [{ name: 'A' }, { name: 'B' }] }) === 'A, B', 'getTrackArtists artists array');
|
|
assert(sanitizeForFilename('a/b:c*?"<>|') === 'a_b_c______', 'sanitize filename');
|
|
assert(buildTrackFilename({ artist: { name: 'X/Y' }, title: 'A:B' }, 'flac') === 'X_Y - A_B.flac', 'filename build');
|
|
|
|
let debouncedCalls = 0;
|
|
const d = debounce(() => {
|
|
debouncedCalls += 1;
|
|
}, 30);
|
|
d();
|
|
d();
|
|
d();
|
|
await sleep(60);
|
|
assert(debouncedCalls === 1, 'debounce should collapse calls');
|
|
}
|
|
|
|
async function testCache() {
|
|
const { APICache } = loadModule('src/cache.js', ['APICache']);
|
|
|
|
const cache = new APICache({ maxSize: 2, ttl: 20 });
|
|
await cache.set('a', 'k1', 1);
|
|
assert((await cache.get('a', 'k1')) === 1, 'cache get/set');
|
|
|
|
await sleep(30);
|
|
assert((await cache.get('a', 'k1')) === null, 'cache ttl expiry');
|
|
|
|
await cache.set('a', 'k1', 1, 1000);
|
|
await cache.set('a', 'k2', 2, 1000);
|
|
await cache.set('a', 'k3', 3, 1000);
|
|
const stats = cache.getCacheStats();
|
|
assert(stats.size <= 2, 'cache maxSize prune');
|
|
|
|
await cache.clear();
|
|
assert(cache.getCacheStats().size === 0, 'cache clear');
|
|
}
|
|
|
|
async function testLyrics() {
|
|
const { escapeHtml } = loadModule('src/utils.js', ['escapeHtml']);
|
|
|
|
let fetchCount = 0;
|
|
const mockFetch = async () => {
|
|
fetchCount += 1;
|
|
return createMockResponse({
|
|
jsonData: {
|
|
syncedLyrics: '[00:00.00]Line 1\n[00:10.00]Line 2',
|
|
plainLyrics: 'Line 1\nLine 2',
|
|
},
|
|
});
|
|
};
|
|
|
|
const { LyricsManager } = loadModule('src/lyrics.js', ['LyricsManager'], { fetch: mockFetch, escapeHtml });
|
|
const lm = new LyricsManager();
|
|
|
|
const payload = await lm.fetchLyrics({ id: 1, artistName: 'Artist', title: 'Track' });
|
|
assert(payload.synced === true, 'lyrics synced expected');
|
|
assert(payload.lines.length === 2, 'lyrics lines parsed expected');
|
|
assert(lm.getCurrentLineIndex(9.5, payload.lines) === 0, 'line index before second line');
|
|
assert(lm.getCurrentLineIndex(10.1, payload.lines) === 1, 'line index second line');
|
|
|
|
const html = lm.renderLyrics(payload, 1);
|
|
assert(html.includes('lyric-line active'), 'active lyric class expected');
|
|
|
|
const payloadCached = await lm.fetchLyrics({ id: 1, artistName: 'Artist', title: 'Track' });
|
|
assert(payloadCached === payload, 'lyrics should come from cache for same track');
|
|
assert(fetchCount === 1, 'lyrics fetch should be cached');
|
|
}
|
|
|
|
async function testDownloads() {
|
|
const { buildTrackFilename } = loadModule('src/utils.js', ['buildTrackFilename']);
|
|
|
|
const clickState = { clicked: 0, lastName: null };
|
|
const mockDocument = {
|
|
body: { appendChild() {}, removeChild() {} },
|
|
createElement() {
|
|
return {
|
|
href: '',
|
|
download: '',
|
|
click() {
|
|
clickState.clicked += 1;
|
|
clickState.lastName = this.download;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const mockURL = {
|
|
createObjectURL() {
|
|
return 'blob:test';
|
|
},
|
|
revokeObjectURL() {},
|
|
};
|
|
|
|
const api = {
|
|
async downloadTrackBlob(id, quality, onProgress) {
|
|
onProgress?.({ receivedBytes: 50, totalBytes: 100 });
|
|
onProgress?.({ receivedBytes: 100, totalBytes: 100 });
|
|
return {
|
|
blob: new Blob(['abc']),
|
|
track: { id, title: 'Track', artist: { name: 'Artist' } },
|
|
};
|
|
},
|
|
};
|
|
|
|
const { DownloadManager } = loadModule('src/downloads.js', ['DownloadManager'], {
|
|
buildTrackFilename,
|
|
document: mockDocument,
|
|
URL: mockURL,
|
|
AbortController,
|
|
Blob,
|
|
});
|
|
|
|
const dm = new DownloadManager(api);
|
|
let latestTasks = [];
|
|
dm.setOnChange((tasks) => {
|
|
latestTasks = tasks;
|
|
});
|
|
|
|
await dm.downloadTrack({ id: 1, title: 'Track', artist: { name: 'Artist' } }, 'LOSSLESS');
|
|
|
|
assert(clickState.clicked === 1, 'download should trigger anchor click');
|
|
assert(/\.flac$/.test(clickState.lastName), 'lossless should produce .flac filename');
|
|
assert(latestTasks.some((t) => t.status === 'done'), 'at least one done task expected');
|
|
|
|
dm.clearFinished();
|
|
assert(dm.getTasks().length === 0, 'clearFinished should empty done task list');
|
|
}
|
|
|
|
async function testPlayer() {
|
|
const { formatDuration } = loadModule('src/utils.js', ['formatDuration']);
|
|
|
|
class MockAudio {
|
|
constructor() {
|
|
this.events = new Map();
|
|
this.duration = 120;
|
|
this.currentTime = 0;
|
|
this.paused = true;
|
|
this.src = '';
|
|
}
|
|
addEventListener(name, fn) {
|
|
if (!this.events.has(name)) this.events.set(name, []);
|
|
this.events.get(name).push(fn);
|
|
}
|
|
dispatch(name) {
|
|
const list = this.events.get(name) || [];
|
|
list.forEach((fn) => fn());
|
|
}
|
|
async play() {
|
|
this.paused = false;
|
|
}
|
|
pause() {
|
|
this.paused = true;
|
|
}
|
|
}
|
|
|
|
const audio = new MockAudio();
|
|
const progressEl = {
|
|
value: '0',
|
|
_listeners: {},
|
|
addEventListener(name, fn) {
|
|
this._listeners[name] = fn;
|
|
},
|
|
dispatch(name) {
|
|
this._listeners[name]?.();
|
|
},
|
|
};
|
|
const currentTimeEl = { textContent: '' };
|
|
const durationEl = { textContent: '' };
|
|
|
|
const dashState = { initialized: 0 };
|
|
const mockWindow = {
|
|
dashjs: {
|
|
MediaPlayer() {
|
|
return {
|
|
create() {
|
|
return {
|
|
initialize() {
|
|
dashState.initialized += 1;
|
|
},
|
|
reset() {},
|
|
};
|
|
},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
|
|
const { SimplePlayer } = loadModule('src/player.js', ['SimplePlayer'], {
|
|
formatDuration,
|
|
window: mockWindow,
|
|
URL: { revokeObjectURL() {} },
|
|
});
|
|
|
|
const p = new SimplePlayer({ audio, progressEl, currentTimeEl, durationEl });
|
|
p.setQueue([{ id: 1 }, { id: 2 }], 0);
|
|
|
|
let changed = null;
|
|
p.onTrackChanged = (track) => {
|
|
changed = track;
|
|
};
|
|
|
|
await p.playCurrent(async (track) => ({ streamUrl: `https://cdn/${track.id}.flac`, isDash: false }));
|
|
assert(audio.src.includes('/1.flac'), 'player should set direct src');
|
|
assert(changed?.id === 1, 'onTrackChanged should receive current track');
|
|
|
|
await p.playNext(async (track) => ({ streamUrl: `https://cdn/${track.id}.flac`, isDash: false }));
|
|
assert(p.getCurrentTrack().id === 2, 'playNext should move queue forward');
|
|
|
|
await p.playPrev(async (track) => ({ streamUrl: `blob:${track.id}`, isDash: true }));
|
|
assert(dashState.initialized >= 1, 'dash path should initialize dashjs');
|
|
|
|
audio.currentTime = 30;
|
|
audio.dispatch('timeupdate');
|
|
assert(progressEl.value === '25', 'timeupdate should update progress value');
|
|
|
|
progressEl.value = '50';
|
|
progressEl.dispatch('input');
|
|
assert(audio.currentTime === 60, 'input scrubbing should update currentTime');
|
|
|
|
await p.togglePlayPause();
|
|
assert(audio.paused === true, 'toggle should pause when currently playing');
|
|
await p.togglePlayPause();
|
|
assert(audio.paused === false, 'toggle should play when currently paused');
|
|
}
|
|
|
|
async function testAPI() {
|
|
const { APICache } = loadModule('src/cache.js', ['APICache']);
|
|
const { getTrackArtists } = loadModule('src/utils.js', ['getTrackArtists']);
|
|
|
|
const uptime = { api: ['https://api.test'], streaming: ['https://stream.test'] };
|
|
const albumPage1 = {
|
|
data: {
|
|
id: 5,
|
|
title: 'Album 5',
|
|
numberOfTracks: 2,
|
|
artist: { id: 9, name: 'Artist 9' },
|
|
items: [
|
|
{
|
|
item: {
|
|
id: 101,
|
|
title: 'A',
|
|
duration: 100,
|
|
album: { id: 5, title: 'Album 5', cover: 'c-1' },
|
|
artist: { name: 'Artist 9' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const albumPage2 = {
|
|
data: {
|
|
items: [
|
|
{
|
|
item: {
|
|
id: 102,
|
|
title: 'B',
|
|
duration: 110,
|
|
album: { id: 5, title: 'Album 5', cover: 'c-1' },
|
|
artist: { name: 'Artist 9' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const routeMap = new Map([
|
|
['https://tidal-uptime.jiffy-puffs-1j.workers.dev/', createMockResponse({ jsonData: uptime })],
|
|
['https://api.test/search/?s=test', createMockResponse({ jsonData: { data: { items: [{ item: { id: 1, title: 'T', duration: 200, album: { title: 'Alb', cover: 'a-b' }, artist: { name: 'Art' } } }] } } })],
|
|
['https://api.test/search/?al=test', createMockResponse({ jsonData: { data: { items: [{ item: { id: 5, title: 'Album 5', numberOfTracks: 2, artist: { id: 9, name: 'Artist 9' }, cover: 'c-1' } }] } } })],
|
|
['https://api.test/search/?a=test', createMockResponse({ jsonData: { data: { items: [{ item: { id: 9, name: 'Artist 9', picture: 'p-1' } }] } } })],
|
|
['https://api.test/album/?id=5', createMockResponse({ jsonData: albumPage1 })],
|
|
['https://api.test/album/?id=5&offset=1&limit=500', createMockResponse({ jsonData: albumPage2 })],
|
|
['https://api.test/artist/?id=9', createMockResponse({ jsonData: { data: { artist: { id: 9, name: 'Artist 9', picture: 'p-1' } } } })],
|
|
['https://api.test/artist/?f=9&skip_tracks=true', createMockResponse({ jsonData: { data: [{ items: [{ item: { id: 101, title: 'A', duration: 100, album: { id: 5, title: 'Album 5', cover: 'c-1' }, artist: { id: 9, name: 'Artist 9' } } }, { item: { id: 5, title: 'Album 5', numberOfTracks: 2, artist: { id: 9, name: 'Artist 9' }, type: 'ALBUM' } }] }] } })],
|
|
['https://api.test/info/?id=101', createMockResponse({ jsonData: { data: { id: 101, title: 'A', duration: 100, album: { id: 5, title: 'Album 5', cover: 'c-1' }, artist: { id: 9, name: 'Artist 9' } } } })],
|
|
['https://api.test/recommendations/?id=101', createMockResponse({ jsonData: { data: { items: [{ track: { id: 202, title: 'Rec', duration: 90, album: { title: 'R', cover: 'r-1' }, artist: { name: 'Rec Artist' } } }] } } })],
|
|
['https://stream.test/track/?id=101&quality=HI_RES_LOSSLESS', createMockResponse({ jsonData: { data: [{ OriginalTrackUrl: 'https://cdn.test/file.flac' }] } })],
|
|
['https://stream.test/track/?id=202&quality=HI_RES_LOSSLESS', createMockResponse({ jsonData: { data: [{ manifest: Buffer.from(JSON.stringify({ urls: ['https://cdn.test/rec.flac'] })).toString('base64') }] } })],
|
|
['https://stream.test/track/?id=101&quality=LOSSLESS', createMockResponse({ jsonData: { data: [{ OriginalTrackUrl: 'https://cdn.test/file.flac' }] } })],
|
|
['https://cdn.test/file.flac', createMockResponse({ headers: { 'content-length': '3', 'content-type': 'audio/flac' }, blobData: new Blob(['abc']) })],
|
|
]);
|
|
|
|
const mockFetch = async (url) => {
|
|
const key = String(url);
|
|
if (!routeMap.has(key)) throw new Error(`Unexpected fetch URL: ${key}`);
|
|
return routeMap.get(key);
|
|
};
|
|
|
|
const mockURL = {
|
|
createObjectURL() {
|
|
return 'blob:manifest';
|
|
},
|
|
revokeObjectURL() {},
|
|
};
|
|
|
|
const { GreyscaleAPI } = loadModule('src/api.js', ['GreyscaleAPI'], {
|
|
APICache,
|
|
getTrackArtists,
|
|
fetch: mockFetch,
|
|
Blob,
|
|
URL: mockURL,
|
|
atob: (v) => Buffer.from(v, 'base64').toString('utf8'),
|
|
setInterval: () => 0,
|
|
});
|
|
|
|
const api = new GreyscaleAPI();
|
|
|
|
const instances = await api.initInstances();
|
|
assert(instances.api[0] === 'https://api.test', 'initInstances should load from uptime');
|
|
|
|
const tracks = await api.searchTracks('test');
|
|
assert(tracks.items.length === 1 && tracks.items[0].id === 1, 'searchTracks should return normalized data');
|
|
|
|
const albums = await api.searchAlbums('test');
|
|
assert(albums.items.length === 1 && albums.items[0].id === 5, 'searchAlbums should return data');
|
|
|
|
const artists = await api.searchArtists('test');
|
|
assert(artists.items.length === 1 && artists.items[0].id === 9, 'searchArtists should return data');
|
|
|
|
const album = await api.getAlbum(5);
|
|
assert(album.tracks.length === 2, 'getAlbum should include paginated tracks');
|
|
|
|
const artist = await api.getArtist(9);
|
|
assert(artist.name === 'Artist 9', 'getArtist should parse artist');
|
|
|
|
const meta = await api.getTrackMetadata(101);
|
|
assert(meta.id === 101, 'getTrackMetadata should resolve track');
|
|
|
|
const recs = await api.getTrackRecommendations(101);
|
|
assert(Array.isArray(recs) && recs.length === 1, 'getTrackRecommendations should return list');
|
|
|
|
const streamDirect = await api.getTrackStream(101, 'HI_RES_LOSSLESS');
|
|
assert(streamDirect.isDash === false && streamDirect.streamUrl.includes('file.flac'), 'direct stream expected');
|
|
|
|
const streamManifest = await api.getTrackStream(202, 'HI_RES_LOSSLESS');
|
|
assert(streamManifest.streamUrl === 'https://cdn.test/rec.flac', 'manifest stream decode expected');
|
|
|
|
const dl = await api.downloadTrackBlob(101, 'LOSSLESS');
|
|
assert(dl.blob instanceof Blob, 'downloadTrackBlob should return blob');
|
|
|
|
const cover = api.getCoverUrl('a-b-c', 320);
|
|
assert(cover.includes('/a/b/c/320x320.jpg'), 'cover URL format');
|
|
|
|
const pic = api.getArtistPictureUrl('x-y-z', 640);
|
|
assert(pic.includes('/x/y/z/640x640.jpg'), 'artist picture URL format');
|
|
}
|
|
|
|
// -----------------------------------
|
|
// Live integration suites (--live)
|
|
// -----------------------------------
|
|
|
|
const liveState = {
|
|
api: null,
|
|
lyricsManager: null,
|
|
track: null,
|
|
albumId: null,
|
|
artistId: null,
|
|
searchQuery: 'daft punk',
|
|
};
|
|
|
|
async function loadLiveModules() {
|
|
if (liveState.api && liveState.lyricsManager) return;
|
|
|
|
const apiUrl = pathToFileURL(path.join(ROOT, 'src/api.js')).href;
|
|
const lyricsUrl = pathToFileURL(path.join(ROOT, 'src/lyrics.js')).href;
|
|
|
|
const { GreyscaleAPI } = await import(apiUrl);
|
|
const { LyricsManager } = await import(lyricsUrl);
|
|
|
|
liveState.api = new GreyscaleAPI();
|
|
liveState.lyricsManager = new LyricsManager();
|
|
}
|
|
|
|
async function withTimeout(promise, ms, name) {
|
|
let t;
|
|
const timeout = new Promise((_, reject) => {
|
|
t = setTimeout(() => reject(new Error(`${name} timed out after ${ms}ms`)), ms);
|
|
});
|
|
try {
|
|
return await Promise.race([promise, timeout]);
|
|
} finally {
|
|
clearTimeout(t);
|
|
}
|
|
}
|
|
|
|
async function liveInitInstances() {
|
|
await loadLiveModules();
|
|
const instances = await withTimeout(liveState.api.initInstances(), 15000, 'initInstances');
|
|
assert(Array.isArray(instances.api) && instances.api.length > 0, 'api instances should be non-empty');
|
|
assert(Array.isArray(instances.streaming) && instances.streaming.length > 0, 'streaming instances should be non-empty');
|
|
}
|
|
|
|
async function liveSearches() {
|
|
await loadLiveModules();
|
|
|
|
const [tracksRes, albumsRes, artistsRes] = await Promise.all([
|
|
withTimeout(liveState.api.searchTracks(liveState.searchQuery), 20000, 'searchTracks'),
|
|
withTimeout(liveState.api.searchAlbums(liveState.searchQuery), 20000, 'searchAlbums'),
|
|
withTimeout(liveState.api.searchArtists(liveState.searchQuery), 20000, 'searchArtists'),
|
|
]);
|
|
|
|
assert(Array.isArray(tracksRes.items), 'tracks search result should have items[]');
|
|
assert(Array.isArray(albumsRes.items), 'albums search result should have items[]');
|
|
assert(Array.isArray(artistsRes.items), 'artists search result should have items[]');
|
|
|
|
const firstTrack = tracksRes.items.find((t) => t?.id);
|
|
if (!firstTrack) skip('No real track returned by searchTracks');
|
|
|
|
liveState.track = firstTrack;
|
|
liveState.albumId = firstTrack.album?.id || albumsRes.items?.[0]?.id || null;
|
|
liveState.artistId = firstTrack.artist?.id || artistsRes.items?.[0]?.id || null;
|
|
}
|
|
|
|
async function liveGetAlbumArtistMetadata() {
|
|
await loadLiveModules();
|
|
if (!liveState.track) skip('No track selected from search');
|
|
|
|
if (!liveState.albumId) skip('No album id available from real search results');
|
|
const album = await withTimeout(liveState.api.getAlbum(liveState.albumId), 25000, 'getAlbum');
|
|
assert(album?.album, 'getAlbum should return album object');
|
|
assert(Array.isArray(album?.tracks), 'getAlbum should return tracks[]');
|
|
|
|
if (!liveState.artistId) skip('No artist id available from real search results');
|
|
const artist = await withTimeout(liveState.api.getArtist(liveState.artistId), 25000, 'getArtist');
|
|
assert(artist?.name, 'getArtist should return artist name');
|
|
assert(Array.isArray(artist?.albums), 'getArtist should return albums[]');
|
|
|
|
const meta = await withTimeout(liveState.api.getTrackMetadata(liveState.track.id), 20000, 'getTrackMetadata');
|
|
assert(meta?.id, 'getTrackMetadata should return id');
|
|
liveState.track = meta;
|
|
}
|
|
|
|
async function liveRecommendations() {
|
|
await loadLiveModules();
|
|
if (!liveState.track?.id) skip('No track id available for recommendations');
|
|
|
|
const recs = await withTimeout(liveState.api.getTrackRecommendations(liveState.track.id), 20000, 'getTrackRecommendations');
|
|
assert(Array.isArray(recs), 'recommendations should return array');
|
|
}
|
|
|
|
async function liveStreamAndReachability() {
|
|
await loadLiveModules();
|
|
if (!liveState.track?.id) skip('No track id available for stream test');
|
|
|
|
const stream = await withTimeout(liveState.api.getTrackStream(liveState.track.id, 'HI_RES_LOSSLESS'), 20000, 'getTrackStream');
|
|
assert(stream && typeof stream.streamUrl === 'string', 'streamUrl should be string');
|
|
|
|
if (stream.streamUrl.startsWith('blob:')) {
|
|
return 'DASH blob stream resolved';
|
|
}
|
|
|
|
const head = await withTimeout(fetch(stream.streamUrl, { method: 'HEAD' }), 15000, 'stream HEAD');
|
|
assert(head.ok, `stream HEAD should be OK, got ${head.status}`);
|
|
}
|
|
|
|
async function liveDownloadBlob() {
|
|
await loadLiveModules();
|
|
if (!liveState.track?.id) skip('No track id available for downloadTrackBlob');
|
|
|
|
try {
|
|
const dl = await withTimeout(liveState.api.downloadTrackBlob(liveState.track.id, 'LOW'), 30000, 'downloadTrackBlob');
|
|
assert(dl?.blob instanceof Blob, 'downloadTrackBlob should return Blob');
|
|
assert(dl.blob.size > 0, 'downloaded Blob should have size > 0');
|
|
} catch (e) {
|
|
if (String(e?.message || '').includes('DASH stream download is not yet supported')) {
|
|
skip('downloadTrackBlob skipped for DASH manifest stream');
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function liveArtworkUrls() {
|
|
await loadLiveModules();
|
|
if (!liveState.track) skip('No track metadata available for artwork test');
|
|
|
|
const coverId = liveState.track?.album?.cover || liveState.track?.cover;
|
|
if (!coverId) skip('No cover id available');
|
|
|
|
const coverUrl = liveState.api.getCoverUrl(coverId, 320);
|
|
const coverRes = await withTimeout(fetch(coverUrl, { method: 'HEAD' }), 15000, 'cover HEAD');
|
|
assert(coverRes.ok, `cover URL should be reachable, got ${coverRes.status}`);
|
|
|
|
if (liveState.track?.artist?.picture) {
|
|
const picUrl = liveState.api.getArtistPictureUrl(liveState.track.artist.picture, 320);
|
|
const picRes = await withTimeout(fetch(picUrl, { method: 'HEAD' }), 15000, 'artist picture HEAD');
|
|
assert(picRes.ok, `artist picture URL should be reachable, got ${picRes.status}`);
|
|
}
|
|
}
|
|
|
|
async function liveLyrics() {
|
|
await loadLiveModules();
|
|
if (!liveState.track) skip('No track available for lyrics test');
|
|
|
|
const lyrics = await withTimeout(
|
|
liveState.lyricsManager.fetchLyrics({
|
|
id: liveState.track.id,
|
|
title: liveState.track.title,
|
|
artistName: liveState.track.artistName || liveState.track.artist?.name,
|
|
}),
|
|
20000,
|
|
'LyricsManager.fetchLyrics'
|
|
);
|
|
|
|
assert(lyrics && typeof lyrics === 'object', 'lyrics should return object');
|
|
assert(Array.isArray(lyrics.lines), 'lyrics.lines should be an array');
|
|
}
|
|
|
|
async function liveCacheRoundTrip() {
|
|
await loadLiveModules();
|
|
const q = liveState.searchQuery;
|
|
|
|
const before = liveState.api.getCacheStats().size;
|
|
await withTimeout(liveState.api.searchTracks(q), 20000, 'searchTracks first');
|
|
const mid = liveState.api.getCacheStats().size;
|
|
await withTimeout(liveState.api.searchTracks(q), 20000, 'searchTracks second');
|
|
const after = liveState.api.getCacheStats().size;
|
|
|
|
assert(mid >= before, 'cache size should not decrease after first fetch');
|
|
assert(after === mid, 'cache size should remain stable on second cached fetch');
|
|
}
|
|
|
|
async function liveErrorHandling() {
|
|
await loadLiveModules();
|
|
|
|
let threw = false;
|
|
try {
|
|
await withTimeout(liveState.api.getTrackMetadata('this-id-should-not-exist-123456789'), 15000, 'invalid track metadata');
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
|
|
assert(threw, 'invalid metadata request should throw');
|
|
}
|
|
|
|
function printSummary() {
|
|
const total = results.length;
|
|
const passed = results.filter((r) => r.status === 'pass').length;
|
|
const failed = results.filter((r) => r.status === 'fail').length;
|
|
const skipped = results.filter((r) => r.status === 'skip').length;
|
|
|
|
const unit = results.filter((r) => r.mode === 'unit');
|
|
const live = results.filter((r) => r.mode === 'live');
|
|
|
|
const unitPassed = unit.filter((r) => r.status === 'pass').length;
|
|
const unitFailed = unit.filter((r) => r.status === 'fail').length;
|
|
const unitSkipped = unit.filter((r) => r.status === 'skip').length;
|
|
|
|
const livePassed = live.filter((r) => r.status === 'pass').length;
|
|
const liveFailed = live.filter((r) => r.status === 'fail').length;
|
|
const liveSkipped = live.filter((r) => r.status === 'skip').length;
|
|
|
|
console.log('\n================ TEST SUMMARY ================');
|
|
console.log(`Mode: ${LIVE_MODE ? 'unit + live integration' : 'unit only'}`);
|
|
console.log(`Total checks: ${total}`);
|
|
console.log(`Passed: ${passed}`);
|
|
console.log(`Failed: ${failed}`);
|
|
console.log(`Skipped: ${skipped}`);
|
|
|
|
console.log('\n-- Unit --');
|
|
console.log(`Checks: ${unit.length}, Passed: ${unitPassed}, Failed: ${unitFailed}, Skipped: ${unitSkipped}`);
|
|
|
|
if (LIVE_MODE) {
|
|
console.log('\n-- Live Integration --');
|
|
console.log(`Checks: ${live.length}, Passed: ${livePassed}, Failed: ${liveFailed}, Skipped: ${liveSkipped}`);
|
|
}
|
|
|
|
const failedTests = results.filter((r) => r.status === 'fail');
|
|
if (failedTests.length > 0) {
|
|
console.log('\nFailed checks:');
|
|
failedTests.forEach((r, i) => {
|
|
console.log(`${i + 1}. [${r.mode}] ${r.name}`);
|
|
});
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log('\nNo failures.');
|
|
process.exitCode = 0;
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log(`Running Greyscale tests (${LIVE_MODE ? 'unit + live integration' : 'unit'})...\n`);
|
|
|
|
const unitSuites = [
|
|
['syntax', testSyntaxChecks],
|
|
['utils', testUtils],
|
|
['cache', testCache],
|
|
['lyrics', testLyrics],
|
|
['downloads', testDownloads],
|
|
['player', testPlayer],
|
|
['api', testAPI],
|
|
];
|
|
|
|
for (const [name, fn] of unitSuites) {
|
|
await runTest(name, fn, 'unit');
|
|
}
|
|
|
|
if (LIVE_MODE) {
|
|
const liveSuites = [
|
|
['live:initInstances', liveInitInstances],
|
|
['live:searches', liveSearches],
|
|
['live:album-artist-metadata', liveGetAlbumArtistMetadata],
|
|
['live:recommendations', liveRecommendations],
|
|
['live:stream-reachability', liveStreamAndReachability],
|
|
['live:downloadTrackBlob', liveDownloadBlob],
|
|
['live:artwork-urls', liveArtworkUrls],
|
|
['live:lyrics', liveLyrics],
|
|
['live:cache-roundtrip', liveCacheRoundTrip],
|
|
['live:error-handling', liveErrorHandling],
|
|
];
|
|
|
|
for (const [name, fn] of liveSuites) {
|
|
await runTest(name, fn, 'live');
|
|
}
|
|
}
|
|
|
|
printSummary();
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('Fatal test harness error:', e);
|
|
process.exit(1);
|
|
});
|