test(frontend): test dymny zbudowanego panelu

Ł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 <noreply@anthropic.com>
This commit is contained in:
lgooseandClaude Bot committed 2026-08-21 01:09:02 +02:00
1 parent bbd8a321f4
commit 2feb963e6a
1 file changed
+167
+167
View File
@@ -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)