import { test, expect } from '@playwright/test'; const baseStatus = { hostname: 'pikit', ready: true, uptime_seconds: 100, load: [0, 0, 0], memory_mb: { total: 1024, free: 512 }, disk_mb: { total: 10240, free: 9000 }, cpu_temp_c: 40, lan_ip: '10.0.0.10', os_version: 'DietPi', auto_updates_enabled: true, auto_updates: { enabled: true }, }; const defaultUpdatesConfig = { enabled: true, scope: 'all', update_time: '04:00', upgrade_time: '04:30', cleanup: true, bandwidth_limit_kbps: null, auto_reboot: false, reboot_time: '04:30', reboot_with_users: false, }; async function primeStatus(page, statusData) { await page.route('**/api/status', async (route) => { await route.fulfill({ json: statusData }); }); } async function stubUpdatesConfig(page, cfg = defaultUpdatesConfig) { await page.route('**/api/updates/config', async (route) => { if (route.request().method() === 'POST') { const body = await route.request().postDataJSON(); await route.fulfill({ json: { ...cfg, ...body } }); return; } await route.fulfill({ json: cfg }); }); } test('renders services from status payload', async ({ page }) => { await stubUpdatesConfig(page); const statusData = { ...baseStatus, services: [ { name: 'Pi-hole', port: 8089, scheme: 'http', path: '/admin', url: 'http://pikit:8089/admin', online: true }, { name: 'Netdata', port: 19999, scheme: 'http', url: 'http://pikit:19999', online: false }, ], }; await primeStatus(page, statusData); await page.goto('/'); await expect(page.getByText('Pi-hole')).toBeVisible(); await expect(page.getByText('http://pikit:8089/admin')).toBeVisible(); await expect(page.getByText('Netdata')).toBeVisible(); }); test('add service shows busy overlay and new card', async ({ page }) => { await stubUpdatesConfig(page); await page.addInitScript(() => { window.__pikitTest = window.__pikitTest || {}; window.__pikitTest.forceAccordionsOpen = true; window.__pikitTest.forceServiceFormVisible = () => { const ids = ['acc-services', 'svcName', 'svcPort', 'svcPath', 'svcScheme', 'svcAddBtn']; ids.forEach((id) => { const el = document.getElementById(id); if (el) { el.style.display = 'block'; el.style.opacity = '1'; el.style.visibility = 'visible'; el.style.maxHeight = '2000px'; } }); }; }); const statusData = { ...baseStatus, services: [] }; await page.route('**/api/status', async (route) => { await route.fulfill({ json: statusData }); }); await page.route('**/api/services/add', async (route) => { const body = await route.request().postDataJSON(); statusData.services.push({ name: body.name, port: body.port, scheme: body.scheme, path: body.path, url: `${body.scheme}://pikit:${body.port}${body.path || ''}`, online: true, }); await route.fulfill({ status: 200, json: { services: statusData.services, message: 'Added' } }); }); await page.goto('/'); await page.click('#advBtn'); await page.evaluate(() => { const acc = document.querySelector('button[data-target="acc-services"]')?.closest('.accordion'); acc?.classList.add('open'); window.__pikitTest?.forceServiceFormVisible?.(); }); await page.fill('#svcName', 'Grafana', { force: true }); await page.fill('#svcPort', '3000', { force: true }); await page.fill('#svcPath', '/dashboards', { force: true }); await page.evaluate(() => { const sel = document.getElementById('svcScheme'); if (sel) { sel.value = 'http'; sel.dispatchEvent(new Event('change', { bubbles: true })); } }); await page.click('#svcAddBtn', { force: true }); await expect(page.getByText('Grafana')).toBeVisible(); await expect(page.getByText('http://pikit:3000/dashboards')).toBeVisible(); }); test('path validation rejects absolute URLs', async ({ page }) => { await stubUpdatesConfig(page); await page.addInitScript(() => { window.__pikitTest = window.__pikitTest || {}; window.__pikitTest.forceAccordionsOpen = true; window.__pikitTest.forceServiceFormVisible = () => { const ids = ['acc-services', 'svcName', 'svcPort', 'svcPath', 'svcScheme', 'svcAddBtn']; ids.forEach((id) => { const el = document.getElementById(id); if (el) { el.style.display = 'block'; el.style.opacity = '1'; el.style.visibility = 'visible'; el.style.maxHeight = '2000px'; } }); }; }); const statusData = { ...baseStatus, services: [] }; await primeStatus(page, statusData); await page.goto('/'); await page.click('#advBtn'); await page.evaluate(() => { const acc = document.querySelector('button[data-target="acc-services"]')?.closest('.accordion'); acc?.classList.add('open'); window.__pikitTest?.forceServiceFormVisible?.(); }); await page.fill('#svcName', 'BadPath', { force: true }); await page.fill('#svcPort', '8080', { force: true }); await page.fill('#svcPath', 'http://example.com', { force: true }); await page.click('#svcAddBtn', { force: true }); await expect(page.getByText('Path must be relative (e.g. /admin) or blank.')).toBeVisible(); }); test('edit service updates path and scheme', async ({ page }) => { await stubUpdatesConfig(page); await page.addInitScript(() => { window.__pikitTest = window.__pikitTest || {}; window.__pikitTest.forceAccordionsOpen = true; }); const statusData = { ...baseStatus, services: [ { name: 'Uptime Kuma', port: 3001, scheme: 'http', path: '', url: 'http://pikit:3001', online: true }, ], }; await page.route('**/api/status', async (route) => { await route.fulfill({ json: statusData }); }); await page.route('**/api/services/update', async (route) => { const body = await route.request().postDataJSON(); statusData.services = statusData.services.map((s) => s.port === body.port ? { ...s, scheme: body.scheme, path: body.path, url: `${body.scheme}://pikit:${s.port}${body.path || ''}` } : s ); await route.fulfill({ status: 200, json: { services: statusData.services, message: 'Service updated' } }); }); await page.goto('/'); await page.click('.menu-btn'); await page.fill('#menuPath', '/status'); await page.selectOption('#menuScheme', 'https'); const updateResp = await Promise.all([ page.waitForResponse((r) => r.url().includes('/api/services/update') && r.status() === 200), page.click('#menuSaveBtn'), ]).then((res) => res[0]); expect(updateResp.ok()).toBeTruthy(); const statusAfter = await page.evaluate(async () => { const res = await fetch('/api/status'); return res.json(); }); const svc = statusAfter.services.find((s) => s.port === 3001); expect(svc).toBeTruthy(); expect(svc.url).toContain('https://pikit:3001/status'); await page.reload(); await expect(page.locator('.service-url')).toContainText('https://pikit:3001/status', { timeout: 8000 }); }); test('remove service updates list', async ({ page }) => { await stubUpdatesConfig(page); let services = [ { name: 'RemoveMe', port: 9000, scheme: 'http', url: 'http://pikit:9000', online: true }, ]; const statusData = { ...baseStatus, services }; await page.route('**/api/status', async (route) => { await route.fulfill({ json: { ...statusData, services } }); }); await page.route('**/api/services/remove', async (route) => { const body = await route.request().postDataJSON(); services = services.filter((s) => s.port !== body.port); await route.fulfill({ status: 200, json: { services, message: 'Removed' } }); }); await page.goto('/'); await page.click('.menu-btn'); await page.click('#menuRemoveBtn'); await expect(page.locator('.pill', { hasText: 'RemoveMe' })).toHaveCount(0, { timeout: 2000 }); });