Files
client/greyscale/tests/test.js
T
2026-04-17 16:52:51 -04:00

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);
});