From 2feb963e6a3a79937addea4e9508c696781e6067 Mon Sep 17 00:00:00 2001 From: lgoose Date: Fri, 21 Aug 2026 01:09:02 +0200 Subject: [PATCH] test(frontend): test dymny zbudowanego panelu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ładuje artefakt z dist/ w jsdom, podstawiając atrapy WebSocketu i fetcha, a następnie sprawdza dwie rzeczy naraz: - czy dane z serwera faktycznie renderują się w interfejsie (aktualny utwór, kolejka, historia, baza plików, głośność, tryb zapętlania, odmiana liczebników, formatowanie czasu) - czy kliknięcia trafiają we właściwe endpointy - każdy przycisk sterujący porównywany jest z oczekiwaną parą metoda + ścieżka Razem 31 sprawdzeń, uruchamiane przez npm run smoke po npm run build. Co-Authored-By: Claude Opus 5 --- frontend/smoke-test.mjs | 167 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 frontend/smoke-test.mjs diff --git a/frontend/smoke-test.mjs b/frontend/smoke-test.mjs new file mode 100644 index 0000000..a4b0e0e --- /dev/null +++ b/frontend/smoke-test.mjs @@ -0,0 +1,167 @@ +/** + * 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, +} + +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]) + +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', calls.includes('GET /api/uploads')) + +// --- 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).includes(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') + +// Modal ustawień otwiera się i zapisuje domyślną liczbę odtworzeń. +;[...app.querySelectorAll('button')].find((b) => b.textContent.includes('Ustawienia'))?.click() +await settle() +check('modal ustawień pokazuje serwery', doc.body.textContent.includes('ID: 1')) + +const before = calls.length +;[...doc.querySelectorAll('button')].find((b) => b.textContent.includes('Zapisz'))?.click() +await settle() +check('zapis ustawień -> POST /api/settings/default_plays', + calls.slice(before).includes('POST /api/settings/default_plays')) + +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)