/** * Test dymny zbudowanego panelu. * * Ładuje artefakt z dist/ do jsdom, podstawia atrapę WebSocketu i fetcha, * a następnie sprawdza dwie rzeczy: czy interfejs renderuje dane z serwera * i czy kliknięcia trafiają w odpowiednie endpointy API. * * Uruchomienie: npm run build && npm run smoke */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' import { JSDOM } from 'jsdom' const DIST = resolve(dirname(fileURLToPath(import.meta.url)), 'dist') const status = { playing: true, paused: false, loop_single: false, loop_playlist: true, volume: 42, current: { id: 'x1', title: 'Testowy utwór', url: 'https://youtu.be/abc', duration: 200, elapsed: 12.3, plays_left: 4, }, queue: [ { id: 'x1', title: 'Testowy utwór', url: 'https://youtu.be/abc', plays_left: 4 }, { id: 'x2', title: 'Drugi utwór', url: null, plays_left: 2 }, ], queue_index: 0, history: [ { id: 'h1', title: 'Stary utwór', url: 'https://youtu.be/old', duration: 65, is_local: false, filepath: null, }, ], voice_channels: [{ guild_id: '1', guild_name: 'Serwer', channel_id: '10', channel_name: 'Muzyka' }], active_voice_channel: { guild_id: '1', guild_name: 'Serwer', channel_id: '10', channel_name: 'Muzyka', }, text_channels: [], active_text_channel_id: null, guilds: [ { guild_id: '1', guild_name: 'Serwer', text_channels: [{ channel_id: '20', channel_name: 'ogolny' }], active_notification_channel_id: '20', }, ], default_plays: 5, saved_queue_available: true, } const calls = [] const html = readFileSync(`${DIST}/index.html`, 'utf8') const dom = new JSDOM(html, { url: 'http://localhost:8000/', runScripts: 'dangerously', pretendToBeVisual: true, beforeParse(window) { window.WebSocket = class { constructor(url) { this.url = url setTimeout(() => { this.onopen?.() this.onmessage?.({ data: JSON.stringify(status) }) }, 0) } close() {} send() {} } window.fetch = async (path, init) => { calls.push(`${init?.method ?? 'GET'} ${path}`) return { ok: true, status: 200, json: async () => String(path).includes('/api/uploads') ? { files: [{ filename: 'sample.mp3', size_mb: 3.2, mtime: 1 }] } : { ...status, mode: 'single' }, } } }, }) const scriptSrc = html.match(/src="(\/assets\/[^"]+\.js)"/)[1] dom.window.eval(readFileSync(`${DIST}${scriptSrc}`, 'utf8')) const settle = () => new Promise((done) => setTimeout(done, 250)) await settle() const doc = dom.window.document const app = doc.getElementById('app') const text = () => app.textContent const byTitle = (title) => app.querySelector(`[title="${title}"]`) const results = [] const check = (name, ok) => results.push([name, ok]) const hasCall = (expected) => calls.some((call) => call === expected || call.startsWith(`${expected}?`)) check('aplikacja zamontowana', app.children.length > 0) check('nagłówek', text().includes('ŻyrarBOT')) check('status połączenia', text().includes('Połączony: Muzyka (Serwer)')) check('aktualny utwór', text().includes('Testowy utwór')) check('drugi utwór z kolejki', text().includes('Drugi utwór')) check('odmiana licznika kolejki', text().includes('2 utwory')) check('historia', text().includes('Stary utwór')) check('plik z serwerowej bazy', text().includes('sample.mp3')) check('odmiana licznika plików', text().includes('1 plik')) check('badge zapętlania kolejki', text().includes('Zapętlenie kolejki')) check('głośność z serwera', text().includes('42%')) check('sformatowany czas trwania', text().includes('3:20')) check('przycisk Odłącz przy połączeniu', text().includes('Odłącz')) check('ikony renderowane jako SVG', app.querySelectorAll('svg').length > 10) check('pozycje list', app.querySelectorAll('.queue-item').length === 4) check('podświetlona aktywna pozycja', app.querySelectorAll('.playing-item').length === 1) check('animacja winylu przy odtwarzaniu', app.querySelector('.vinyl-record.playing') !== null) check('pobrano listę plików na starcie', hasCall('GET /api/uploads')) check('przycisk przywracania kolejki', text().includes('Przywróć ostatnią kolejkę')) // --- Interakcje: kliknięcie ma trafić w konkretny endpoint --------------- async function clickExpects(title, expected) { const before = calls.length byTitle(title)?.click() await settle() check(`${title} -> ${expected}`, calls.slice(before).some((call) => call === expected || call.startsWith(`${expected}?`))) } await clickExpects('Wstrzymaj', 'POST /api/pause') await clickExpects('Pomiń utwór', 'POST /api/skip') await clickExpects('Zatrzymaj i wyczyść', 'POST /api/stop') await clickExpects('Zapętlenie: cała kolejka (kliknij, aby zapętlić jeden utwór)', 'POST /api/loop_cycle') await clickExpects('Odtwarzaj od tej pozycji', 'POST /api/jump') await clickExpects('Usuń z kolejki', 'POST /api/remove') await clickExpects('Przesuń w dół', 'POST /api/reorder') await clickExpects('Zwiększ o 1', 'POST /api/queue/plays') await clickExpects('Dodaj do kolejki', 'POST /api/play_file') await clickExpects('Dodaj ponownie do kolejki', 'POST /api/play') await clickExpects('Usuń z serwera', 'DELETE /api/uploads/sample.mp3') const failed = results.filter(([, ok]) => !ok) for (const [name, ok] of results) console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`) console.log(failed.length ? `\n${failed.length} z ${results.length} niepowodzeń` : `\nOK - ${results.length} sprawdzeń`) process.exit(failed.length ? 1 : 0)