// @ts-check const { test, expect } = require('@playwright/test'); // Console/page errors that are environmental (offline backends, missing engines) and // must NOT fail the smoke test. Real JS exceptions (ReferenceError, etc.) still fail. const IGNORE = [/Failed to load resource/i, /favicon/i, /net::ERR/i, /AbortError/i]; function watchForErrors(page) { const errors = []; page.on('pageerror', (e) => errors.push(String(e))); page.on('console', (m) => { if (m.type() === 'error' && !IGNORE.some(r => r.test(m.text()))) errors.push(m.text()); }); return errors; } test('app shell loads without JS errors', async ({ page }) => { const errors = watchForErrors(page); await page.goto('/', { waitUntil: 'domcontentloaded' }); await expect(page.locator('#sidebar')).toBeVisible(); await expect(page.locator('.nav-item').first()).toBeVisible(); // Let dynamic sections + module scripts settle await page.waitForTimeout(1500); const real = errors.filter(e => /ReferenceError|TypeError|SyntaxError|is not defined|is not a function/.test(e)); expect(real, real.join('\n')).toHaveLength(0); await expect.poll(() => page.evaluate(() => !!window.TTVC?.audiobook && !!window.TTVC?.rehearser)).toBeTruthy(); }); // Navigate via the app's global router (sidebar is hidden behind a menu on mobile, // so clicking sidebar pixels is unreliable across viewports). async function navTo(page, section) { await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); await page.evaluate((s) => window.navTo(s), section); } test('core sections render', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); const sections = [ ['s-clone', /Clone a Voice/i], ['s-studio', /Get Voices Online/i], ['s-rehearser', /Script Rehearser/i], ]; for (const [nav, heading] of sections) { await navTo(page, nav); await expect(page.locator('#' + nav).getByText(heading).first()).toBeVisible({ timeout: 10_000 }); } }); test('clone tabs switch and show one panel at a time', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); await navTo(page, 's-clone'); await page.waitForFunction(() => typeof window._cloneScheduleAutoSave === 'function', null, { timeout: 15_000 }); // The loader applies the persisted startup section during its final reveal; // wait for that initialization to finish before changing sections. await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); await page.evaluate(() => window.navTo('s-clone')); const clone = page.locator('#s-clone'); await expect(clone.locator('.clone-src-tab[data-src="url"]')).toBeVisible({ timeout: 10_000 }); // The app uses a fixed bottom status bar; dispatching the real DOM click // avoids a viewport-dependent overlap while still exercising the handler. await clone.locator('.clone-src-tab[data-src="url"]').evaluate((el) => el.click()); await expect(clone.locator('#clone-src-url')).toBeVisible(); await expect(clone.locator('#clone-src-mic')).toBeHidden(); }); test('PWA manifest and service worker are served', async ({ page, request }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); const mani = await request.get('/manifest.webmanifest'); expect(mani.ok()).toBeTruthy(); const sw = await request.get('/sw.js'); expect(sw.ok()).toBeTruthy(); }); test('background job history is rendered without payloads', async ({ page }) => { await page.route('**/api/jobs?limit=50', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ jobs: [{ job_id: 'abc123456789', kind: 'transcription', stage: 'running', detail: 'Transcribing audio', done: false, elapsed: 2.4 }] }), })); await page.goto('/', { waitUntil: 'domcontentloaded' }); await navTo(page, 's-performance'); await expect(page.locator('#background-jobs-card')).toBeVisible(); await expect(page.locator('#background-jobs-list')).toContainText('Transcription'); await expect(page.locator('#background-jobs-list')).toContainText('Transcribing audio'); await expect(page.locator('#background-jobs-list')).toContainText('Cancel'); await expect(page.locator('#background-jobs-list')).not.toContainText('prompt'); }); test('background job history exposes retry only when the server allows it', async ({ page }) => { let retryRequests = 0; await page.route('**/api/jobs?limit=50', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ jobs: [{ job_id: 'live-failed', kind: 'voice_design', stage: 'error', error: 'Temporary provider failure', done: true, elapsed: 3.1, retry_available: true }] }), })); await page.route('**/api/voice-design/retry/live-failed', async route => { retryRequests += 1; await route.fulfill({ json: { job_id: 'new-job' } }); }); await page.goto('/', { waitUntil: 'domcontentloaded' }); await navTo(page, 's-performance'); const jobs = page.locator('#background-jobs-list'); await expect(jobs).toContainText('Temporary provider failure'); await expect(jobs.getByRole('button', { name: 'Retry' })).toBeVisible(); const clicked = await page.evaluate(() => { const button = document.querySelector('#background-jobs-list button'); if (!button) return false; button.click(); return true; }); expect(clicked).toBeTruthy(); await expect.poll(() => retryRequests).toBe(1); }); test('resource diagnostics expose cache, container, and operation metrics', async ({ page }) => { await page.route('**/api/diagnostics', route => route.fulfill({ json: { server_rss_mb: 52.5, config_disk: { used_bytes: 1024, free_bytes: 2048 }, temp_disk: { used_bytes: 512 }, background_jobs: 2, container_memory: { current_bytes: 4096, max_bytes: 8192 }, cache_storage: { background_artifacts_bytes: 256 }, largest_operations: [{ kind: 'voice_design', status: 'completed', elapsed: 4.2 }], } })); await page.goto('/', { waitUntil: 'domcontentloaded' }); await navTo(page, 's-performance'); const diagnostics = page.locator('#resource-diagnostics'); await expect(diagnostics).toContainText('Image cache entries'); await expect(diagnostics).toContainText('Container memory'); await expect(diagnostics).toContainText('Largest recent operations'); await expect(diagnostics).toContainText('voice_design ยท completed'); });