#!/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(``); 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); });