112 lines
5.4 KiB
JavaScript
112 lines
5.4 KiB
JavaScript
// @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);
|
|
});
|