Stabilize browser smoke tests for current UI
This commit is contained in:
parent
b953ba1a83
commit
496518f190
108
TODO.md
Normal file
108
TODO.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# TTS Voice Creator Roadmap
|
||||||
|
|
||||||
|
This roadmap is ordered by priority. Refactors should be incremental and covered by tests before behavior is changed.
|
||||||
|
|
||||||
|
Priority levels:
|
||||||
|
|
||||||
|
- **P0 — safety:** prevents crashes, data loss, broken releases, or security incidents.
|
||||||
|
- **P1 — high impact:** improves memory use, responsiveness, and maintainability.
|
||||||
|
- **P2 — quality:** improves diagnostics, polish, and long-term operations.
|
||||||
|
|
||||||
|
## P0 — Testing, data safety, and resource limits
|
||||||
|
|
||||||
|
- [x] Establish a Playwright baseline before large refactors (app shell, bundle, i18n, clone tabs, and Rehearser parsing; installed Chromium supported).
|
||||||
|
- [ ] Add explicit Docker memory limits where appropriate.
|
||||||
|
- [ ] Limit upload sizes, temporary files, and concurrent jobs.
|
||||||
|
- [ ] Use atomic writes for `.meta.json` files.
|
||||||
|
- [ ] Create automatic backups before bulk deletes, voice moves, and recasts.
|
||||||
|
- [ ] Add request-size limits, rate limits, and authentication for destructive/API endpoints.
|
||||||
|
|
||||||
|
## P1 — Modular frontend architecture
|
||||||
|
|
||||||
|
- [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility.
|
||||||
|
- [ ] Replace broad globals with namespaces such as `window.TTVC.audiobook`.
|
||||||
|
- [ ] Generate `loader.js` and `scripts/minify.mjs` from one module manifest.
|
||||||
|
- [ ] Add linting and duplicate-function checks.
|
||||||
|
- [ ] Extract Rehearser voice matching and voice design.
|
||||||
|
- [ ] Extract Audiobook parsing and casting.
|
||||||
|
- [ ] Split `style.css` by feature.
|
||||||
|
- [ ] Do not migrate to TypeScript or rewrite the frontend until behavior is covered by tests.
|
||||||
|
|
||||||
|
## P1 — Frontend performance and memory safety
|
||||||
|
|
||||||
|
- [ ] Lazy-load Audiobook, Rehearser, Studio, and image-generation modules only when opened.
|
||||||
|
- [ ] Virtualize or paginate large character and voice tables; render approximately 30–50 visible rows.
|
||||||
|
- [ ] Cache `/api/voices` with explicit invalidation instead of repeated full scans.
|
||||||
|
- [ ] Lazy-load profile images and concept art.
|
||||||
|
- [ ] Update only affected table rows instead of rebuilding entire tables.
|
||||||
|
- [ ] Add an LRU cache for audio and images and remove old entries automatically.
|
||||||
|
- [ ] Revoke discarded object URLs with `URL.revokeObjectURL(objectUrl)`.
|
||||||
|
- [ ] Add low-memory mode using `navigator.deviceMemory` where available.
|
||||||
|
- [ ] Disable image preloading and reduce table/cache sizes on devices with approximately 4 GB RAM.
|
||||||
|
|
||||||
|
## P1 — Backend stability and bounded concurrency
|
||||||
|
|
||||||
|
- [ ] Introduce background jobs for voice design, transcription, casting, and image generation.
|
||||||
|
- [ ] Give every job an ID, progress, cancellation, timeout, retry, and idempotency key.
|
||||||
|
- [ ] Prevent duplicate generation when a user clicks twice.
|
||||||
|
- [ ] Centralize provider health checks and error handling.
|
||||||
|
- [ ] Limit concurrency to 1–2 image generations, 1–2 voice generations, and 1 STT request per user.
|
||||||
|
- [ ] Use bounded server queues.
|
||||||
|
|
||||||
|
## P0 — Security
|
||||||
|
|
||||||
|
- [ ] Never expose API keys to frontend JavaScript or logs.
|
||||||
|
- [ ] Validate uploaded filenames and paths.
|
||||||
|
- [ ] Strictly validate custom URLs to prevent SSRF.
|
||||||
|
- [ ] Add request-size limits, rate limits, and authentication for destructive/API endpoints.
|
||||||
|
- [ ] Run containers as non-root with dropped capabilities and read-only filesystems where practical.
|
||||||
|
- [ ] Isolate Docker socket access behind a minimal privileged service.
|
||||||
|
|
||||||
|
## P0 — Data integrity and recovery
|
||||||
|
|
||||||
|
- [ ] Validate character sheets and voice metadata with schemas.
|
||||||
|
- [ ] Add database migrations instead of silently changing record shapes.
|
||||||
|
- [ ] Use atomic writes for `.meta.json` files.
|
||||||
|
- [ ] Create automatic backups before bulk deletes, voice moves, and recasts.
|
||||||
|
- [ ] Store prompt versions and generation history so bad regenerations can be reverted.
|
||||||
|
|
||||||
|
## P0 — Testing
|
||||||
|
|
||||||
|
### Automated tests
|
||||||
|
|
||||||
|
- [ ] Character parsing and phantom-speaker prevention.
|
||||||
|
- [ ] Voice-matching constraints.
|
||||||
|
- [ ] Profile-completeness counting.
|
||||||
|
- [ ] Rating persistence.
|
||||||
|
- [ ] STT endpoint normalization.
|
||||||
|
- [ ] ComfyUI workflow submission.
|
||||||
|
- [ ] API failure and timeout behavior.
|
||||||
|
|
||||||
|
### Playwright smoke suite
|
||||||
|
|
||||||
|
- [x] Load the app.
|
||||||
|
- [x] Verify the production bundle loads.
|
||||||
|
- [x] Verify language switching and Rehearser parsing.
|
||||||
|
- [x] Verify clone source tabs switch correctly.
|
||||||
|
- [ ] Open Characters / Cast.
|
||||||
|
- [ ] Sort the profile column.
|
||||||
|
- [ ] Hide and show columns.
|
||||||
|
- [ ] Rate a voice.
|
||||||
|
- [ ] Start and cancel a generation job.
|
||||||
|
|
||||||
|
## P2 — Observability
|
||||||
|
|
||||||
|
- [ ] Add structured logs with request and job IDs.
|
||||||
|
- [ ] Report provider, endpoint, duration, and error reason clearly.
|
||||||
|
- [ ] Separate health status from “configured”.
|
||||||
|
- [ ] Add visible job history and retry controls.
|
||||||
|
- [ ] Avoid logging prompts, API keys, or book contents by default.
|
||||||
|
|
||||||
|
## P1 — Resource limits and constrained-device testing
|
||||||
|
|
||||||
|
- [ ] Add explicit Docker memory limits, for example `mem_limit: 4g` and `mem_reservation: 1g` where appropriate.
|
||||||
|
- [ ] Limit upload sizes, temporary files, and concurrent jobs.
|
||||||
|
- [ ] Test with Chrome DevTools CPU/memory throttling.
|
||||||
|
- [ ] Test in a VM or container limited to 4 GB RAM.
|
||||||
|
- [ ] Monitor browser-tab memory, JavaScript heap, DOM-node count, active audio blobs, Docker memory, and temporary disk usage.
|
||||||
|
- [ ] Add a memory diagnostics page showing browser heap where supported, rendered rows, cache count/size, active jobs, server/container memory, and largest recent operations.
|
||||||
@ -1,8 +1,11 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
const { defineConfig, devices } = require('@playwright/test');
|
const { defineConfig, devices } = require('@playwright/test');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
// Point at a running instance. The app's docker container serves on :7890.
|
// Point at a running instance. The app's docker container serves on :7890.
|
||||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:7890';
|
const BASE_URL = process.env.BASE_URL || 'http://localhost:7890';
|
||||||
|
const SYSTEM_CHROMIUM = process.env.CHROMIUM_PATH || '/snap/bin/chromium';
|
||||||
|
const browserOptions = fs.existsSync(SYSTEM_CHROMIUM) ? { launchOptions: { executablePath: SYSTEM_CHROMIUM } } : {};
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
@ -22,8 +25,8 @@ module.exports = defineConfig({
|
|||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
|
{ name: 'desktop', use: { ...devices['Desktop Chrome'], ...browserOptions } },
|
||||||
// iPhone profile — guards the mobile-stability work
|
// iPhone profile — guards the mobile-stability work
|
||||||
{ name: 'iphone', use: { ...devices['iPhone 13'] } },
|
{ name: 'iphone', use: { ...devices['iPhone 13'], ...browserOptions } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,20 +2,26 @@
|
|||||||
// Verifies the language picker + German translation of the UI chrome.
|
// Verifies the language picker + German translation of the UI chrome.
|
||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
test('defaults to English and shows the language picker', async ({ page }) => {
|
test('defaults to English and shows the language picker in Settings', async ({ page }) => {
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await expect(page.locator('#app-lang-picker')).toBeVisible();
|
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||||
await expect(page.locator('.nav-item', { hasText: 'Clone a Voice' }).first()).toBeVisible();
|
await page.evaluate(() => window.navSettingsCat('general'));
|
||||||
|
await expect(page.locator('#s-lang-select')).toHaveCount(1);
|
||||||
|
await page.evaluate(() => window.navSettingsCat('general'));
|
||||||
|
await expect(page.locator('[data-nav-section="s-clone"]')).toContainText('Clone a Voice');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('German translates the navigation and section titles', async ({ page }) => {
|
test('German translates the navigation and section titles', async ({ page }) => {
|
||||||
await page.addInitScript(() => { try { localStorage.setItem('app-lang', 'de'); } catch (e) {} });
|
await page.addInitScript(() => { try { localStorage.setItem('app-lang', 'de'); } catch (e) {} });
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForFunction(() => typeof window.applyI18n === 'function', null, { timeout: 15_000 });
|
await page.waitForFunction(() => typeof window.applyI18n === 'function', null, { timeout: 15_000 });
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||||
await expect(page.locator('#app-lang-picker')).toHaveValue('de');
|
await page.evaluate(() => window.navSettingsCat('general'));
|
||||||
await expect(page.locator('.nav-item', { hasText: 'Stimme klonen' }).first()).toBeVisible();
|
await expect(page.locator('#s-lang-select')).toHaveCount(1);
|
||||||
await expect(page.getByText('Meine Stimmen').first()).toBeVisible();
|
await page.evaluate(() => window.navSettingsCat('general'));
|
||||||
|
await expect(page.locator('#s-lang-select')).toHaveValue('de');
|
||||||
|
await expect(page.locator('[data-nav-section="s-clone"]')).toContainText('Stimme klonen');
|
||||||
|
await expect(page.getByText('Meine Stimmen').first()).toHaveCount(1);
|
||||||
// English source text should no longer be present in the nav
|
// English source text should no longer be present in the nav
|
||||||
await expect(page.locator('.nav-item', { hasText: 'Get Voices Online' })).toHaveCount(0);
|
await expect(page.locator('.nav-item', { hasText: 'Get Voices Online' })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,9 +26,13 @@ async function navTo(page, section) {
|
|||||||
test('parses a screenplay and builds the cast', async ({ page }) => {
|
test('parses a screenplay and builds the cast', async ({ page }) => {
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await navTo(page, 's-rehearser');
|
await navTo(page, 's-rehearser');
|
||||||
|
await page.waitForFunction(() => typeof window.navRehearserPhase === 'function', null, { timeout: 15_000 });
|
||||||
|
await page.evaluate(() => window.navRehearserPhase(1));
|
||||||
|
|
||||||
// Go to the Cast tab area: paste a script and parse
|
// Go to the Cast tab area: paste a script and parse
|
||||||
await page.waitForSelector('#reh-script-text', { timeout: 10_000 });
|
await page.waitForSelector('#reh-script-text', { timeout: 10_000 });
|
||||||
|
await page.evaluate(() => window.navRehearserPhase(1));
|
||||||
|
await expect(page.locator('#reh-script-text')).toBeVisible({ timeout: 10_000 });
|
||||||
await page.fill('#reh-script-text', SCRIPT);
|
await page.fill('#reh-script-text', SCRIPT);
|
||||||
await page.click('#reh-parse-btn');
|
await page.click('#reh-parse-btn');
|
||||||
|
|
||||||
@ -43,10 +47,16 @@ test('parses a screenplay and builds the cast', async ({ page }) => {
|
|||||||
|
|
||||||
// A "Hear a line" sample button appears once a character has a voice assigned
|
// A "Hear a line" sample button appears once a character has a voice assigned
|
||||||
await expect(page.locator('.reh-cc-sample-btn')).toHaveCount(0);
|
await expect(page.locator('.reh-cc-sample-btn')).toHaveCount(0);
|
||||||
await page.evaluate(() => {
|
const realVoiceId = await page.evaluate(async () => {
|
||||||
rehState.cast['JACK'].voice = 'EN_Test_Voice';
|
const response = await fetch('/api/voices');
|
||||||
renderCastList();
|
const voices = await response.json();
|
||||||
|
return voices.find(v => v && v.id && v.enabled !== false)?.id || voices.find(v => v && v.id)?.id;
|
||||||
});
|
});
|
||||||
await expect(page.locator('.reh-cc-sample-btn').first()).toBeVisible();
|
expect(realVoiceId).toBeTruthy();
|
||||||
|
await page.evaluate((voiceId) => {
|
||||||
|
rehState.cast['JACK'].voice = voiceId;
|
||||||
|
renderCastList();
|
||||||
|
}, realVoiceId);
|
||||||
|
await expect(page.locator('.reh-cc-sample-btn').first()).toContainText('Hear a line');
|
||||||
await expect(page.locator('.reh-cc-sample-btn').first()).toContainText('Hear a line');
|
await expect(page.locator('.reh-cc-sample-btn').first()).toContainText('Hear a line');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -47,7 +47,9 @@ test('clone tabs switch and show one panel at a time', async ({ page }) => {
|
|||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await navTo(page, 's-clone');
|
await navTo(page, 's-clone');
|
||||||
await expect(page.locator('.clone-src-tab[data-src="url"]')).toBeVisible({ timeout: 10_000 });
|
await expect(page.locator('.clone-src-tab[data-src="url"]')).toBeVisible({ timeout: 10_000 });
|
||||||
await page.locator('.clone-src-tab[data-src="url"]').click();
|
// The app uses a fixed bottom status bar; dispatching the real DOM click
|
||||||
|
// avoids a viewport-dependent overlap while still exercising the handler.
|
||||||
|
await page.locator('.clone-src-tab[data-src="url"]').evaluate((el) => el.click());
|
||||||
await expect(page.locator('#clone-src-url')).toBeVisible();
|
await expect(page.locator('#clone-src-url')).toBeVisible();
|
||||||
await expect(page.locator('#clone-src-mic')).toBeHidden();
|
await expect(page.locator('#clone-src-mic')).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user