diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..ab08e5c --- /dev/null +++ b/TODO.md @@ -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. diff --git a/playwright.config.js b/playwright.config.js index 18dff14..322bb14 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,8 +1,11 @@ // @ts-check const { defineConfig, devices } = require('@playwright/test'); +const fs = require('fs'); // Point at a running instance. The app's docker container serves on :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({ testDir: './tests', @@ -22,8 +25,8 @@ module.exports = defineConfig({ screenshot: 'only-on-failure', }, projects: [ - { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, + { name: 'desktop', use: { ...devices['Desktop Chrome'], ...browserOptions } }, // iPhone profile — guards the mobile-stability work - { name: 'iphone', use: { ...devices['iPhone 13'] } }, + { name: 'iphone', use: { ...devices['iPhone 13'], ...browserOptions } }, ], }); diff --git a/tests/i18n.spec.js b/tests/i18n.spec.js index 2d2a41d..6db0e90 100644 --- a/tests/i18n.spec.js +++ b/tests/i18n.spec.js @@ -2,20 +2,26 @@ // Verifies the language picker + German translation of the UI chrome. 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 expect(page.locator('#app-lang-picker')).toBeVisible(); - await expect(page.locator('.nav-item', { hasText: 'Clone a Voice' }).first()).toBeVisible(); + await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); + 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 }) => { await page.addInitScript(() => { try { localStorage.setItem('app-lang', 'de'); } catch (e) {} }); await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => typeof window.applyI18n === 'function', null, { timeout: 15_000 }); - await page.waitForTimeout(1500); - await expect(page.locator('#app-lang-picker')).toHaveValue('de'); - await expect(page.locator('.nav-item', { hasText: 'Stimme klonen' }).first()).toBeVisible(); - await expect(page.getByText('Meine Stimmen').first()).toBeVisible(); + await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); + 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('#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 await expect(page.locator('.nav-item', { hasText: 'Get Voices Online' })).toHaveCount(0); }); diff --git a/tests/rehearser.spec.js b/tests/rehearser.spec.js index 35ca96b..0585c24 100644 --- a/tests/rehearser.spec.js +++ b/tests/rehearser.spec.js @@ -26,9 +26,13 @@ async function navTo(page, section) { test('parses a screenplay and builds the cast', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); 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 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.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 await expect(page.locator('.reh-cc-sample-btn')).toHaveCount(0); - await page.evaluate(() => { - rehState.cast['JACK'].voice = 'EN_Test_Voice'; - renderCastList(); + const realVoiceId = await page.evaluate(async () => { + const response = await fetch('/api/voices'); + 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'); }); diff --git a/tests/smoke.spec.js b/tests/smoke.spec.js index 3181eba..7f57925 100644 --- a/tests/smoke.spec.js +++ b/tests/smoke.spec.js @@ -47,7 +47,9 @@ test('clone tabs switch and show one panel at a time', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); await navTo(page, 's-clone'); 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-mic')).toBeHidden(); });