// @ts-check // Functional safety net for the Magazine reader UI. Backend calls are mocked // with page.route so this exercises the frontend against a fixed contract // (matching routes/magazine.py's actual response shapes) without needing a // live PyMuPDF/tesseract ingestion — that pipeline has its own coverage in // tests/test_magazine.py and tests/test_magazine_routes.py. const { test, expect } = require('@playwright/test'); const DOC_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const ARTICLE_DETECTED = { id: 'article-001', title: 'The Future of Batteries', author: 'Jane Smith', pages: [3, 8], blocks: ['b1', 'b2', 'b3'], images: [], status: 'detected', confidence: 0.9, section: 'Features', continued_to: [8], audio_status: 'none', estimated_seconds: 96, has_podcast: false, }; const ARTICLE_APPROVED = { ...ARTICLE_DETECTED, status: 'approved' }; const LAYOUT = { page_count: 8, pages: [{ page: 3, width: 595, height: 842 }], blocks: [ { id: 'b1', page: 3, bbox: [50, 60, 470, 50], type: 'title', text: 'The Future of Batteries', reading_order: 0, confidence: 0.9, font_size: 26, font_name: '', bold: true, column: 0, source: 'native' }, { id: 'b2', page: 3, bbox: [50, 120, 200, 14], type: 'byline', text: 'By Jane Smith', reading_order: 1, confidence: 0.9, font_size: 10, font_name: '', bold: false, column: 0, source: 'native' }, { id: 'b3', page: 3, bbox: [50, 150, 200, 300], type: 'body', text: 'Battery technology is entering a new phase. It will take years to mature.', reading_order: 2, confidence: 0.85, font_size: 10, font_name: '', bold: false, column: 0, source: 'native' }, ], contents_candidates: [], }; async function navTo(page, section) { await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); await page.evaluate((s) => window.navTo(s), section); } async function mockBackend(page, { articleStatus = 'detected' } = {}) { const article = articleStatus === 'approved' ? ARTICLE_APPROVED : ARTICLE_DETECTED; await page.route('**/api/reader/docs', route => route.fulfill({ json: { docs: [{ id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', ingest: { status: 'done', articles: 1 } }] }, })); await page.route(`**/api/reader/docs/${DOC_ID}`, route => route.fulfill({ json: { id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', ingest: { status: 'done', articles: 1 } }, })); await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: [article], progress: {}, default_voice: '', podcast: { hosts: [] } }, })); await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: LAYOUT })); await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, route => { if (route.request().method() === 'PATCH') { const body = route.request().postDataJSON(); return route.fulfill({ json: { ...article, ...body, status: body.status || article.status } }); } return route.fulfill({ json: article }); }); await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article, blocks: LAYOUT.blocks }, })); await page.route(`**/api/magazine/${DOC_ID}/pages/*`, route => route.fulfill({ // 1x1 PNG — only geometry/wiring is under test, not real page rendering. body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), contentType: 'image/png', })); await page.route('**/api/voices*', route => route.fulfill({ json: [{ id: 'smoke_voice', name: 'Smoke voice', enabled: true }], })); } test.describe('Magazine reader', () => { test('library lists a magazine and opens its contents tree', async ({ page }) => { await mockBackend(page); await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); await navTo(page, 's-magazine'); await expect(page.locator('#mag-library-list .mag-card')).toHaveCount(1); await page.locator('#mag-library-list .mag-card').first().click(); await expect(page.locator('#mag-view-contents')).toBeVisible(); await expect(page.locator('.mag-toc-title')).toHaveText('The Future of Batteries'); // Non-contiguous pages (a jump-linked continuation) must not read as a range. await expect(page.locator('.mag-toc-row')).toContainText('pp. 3, 8'); await expect(page.locator('.mag-toc-row')).toContainText('90%'); await expect(page.locator('.mag-toc-row')).toContainText('needs review'); }); test('review screen shows layout blocks pre-selected and approves', async ({ page }) => { await mockBackend(page); await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); await navTo(page, 's-magazine'); await page.waitForSelector('#mag-library-list .mag-card'); await page.locator('#mag-library-list .mag-card').first().click(); await page.waitForSelector('.mag-toc-row'); await page.getByRole('button', { name: 'Review' }).click(); await expect(page.locator('#mag-view-review')).toBeVisible(); await expect(page.getByRole('group', { name: 'Review pages' })).toContainText('Page 3'); await expect(page.locator('.mag-review-pagenav').getByRole('button', { name: 'Draw image', exact: true })).toBeVisible(); // The article's own blocks (b1-b3) start ticked; nothing else does. await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(3); await expect(page.locator('#mag-rev-title')).toHaveValue('The Future of Batteries'); await page.getByRole('button', { name: 'Approve' }).click(); await expect(page.locator('#mag-view-contents')).toBeVisible(); }); test('approved article reads, highlights sentences, and links to the PDF page', async ({ page }) => { await mockBackend(page, { articleStatus: 'approved' }); await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); await navTo(page, 's-magazine'); await page.waitForSelector('#mag-library-list .mag-card'); await page.locator('#mag-library-list .mag-card').first().click(); await page.waitForSelector('.mag-toc-row.is-approved'); await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click(); await expect(page.locator('#mag-view-read')).toBeVisible(); await expect(page.locator('#mag-read-title')).toHaveText('The Future of Batteries'); await expect(page.locator('#mag-read-by')).toHaveText('By Jane Smith'); // title (1) + byline (1) + body split into 2 sentences = 4 spans. const sentences = page.locator('#mag-article [data-i]'); await expect(sentences).toHaveCount(4); await expect(sentences.first()).toHaveClass(/is-current/); // Click-to-seek moves the highlight and the progress counter. await sentences.nth(3).click(); await expect(sentences.nth(3)).toHaveClass(/is-current/); await expect(sentences.first()).not.toHaveClass(/is-current/); await expect(page.locator('#mag-read-progress')).toHaveText('4 / 4'); // Switching to the PDF pane shows the page the current sentence came from. await page.getByRole('button', { name: 'Original page' }).click(); await expect(page.locator('#mag-pane-pdf')).toBeVisible(); await expect(page.locator('#mag-page-label')).toHaveText('Page 3'); await expect(page.locator('#mag-page-box')).toBeVisible(); }); test('podcast generation is refused before the article is approved', async ({ page }) => { await mockBackend(page, { articleStatus: 'detected' }); let podcastCalled = false; await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/podcast`, (route) => { podcastCalled = true; return route.fulfill({ status: 400, json: { detail: 'Approve the article before generating a podcast' } }); }); await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); await navTo(page, 's-magazine'); await page.waitForSelector('#mag-library-list .mag-card'); await page.locator('#mag-library-list .mag-card').first().click(); await page.waitForSelector('.mag-toc-row'); // Detected (unapproved) articles show Review only — no Listen/Continue, // so there is no route into the read view (and podcast mode) at all. await expect(page.locator('.mag-toc-row').getByRole('button', { name: /Listen|Continue/ })).toHaveCount(0); expect(podcastCalled).toBe(false); }); test('no console errors navigating the whole flow', async ({ page }) => { const errors = []; page.on('pageerror', (e) => errors.push(String(e))); page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); await mockBackend(page, { articleStatus: 'approved' }); await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); await navTo(page, 's-magazine'); await page.waitForSelector('#mag-library-list .mag-card'); await page.locator('#mag-library-list .mag-card').first().click(); await page.waitForSelector('.mag-toc-row.is-approved'); await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click(); await page.waitForSelector('#mag-article [data-i]'); await page.getByRole('button', { name: 'Original page' }).click(); await page.waitForTimeout(300); const real = errors.filter(e => /ReferenceError|TypeError|SyntaxError|is not defined|is not a function/.test(e)); expect(real, real.join('\n')).toHaveLength(0); }); }); test('drawing a chart links the crop and excludes enclosed narration on approval', async ({ page }) => { await mockBackend(page); let cropped = false; const image = { id: 'manual-chart', page: 3, bbox: [40, 110, 220, 350], filename: 'manual-chart.png', reading_order: 4 }; await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: { ...LAYOUT, images: cropped ? [image] : [] }, })); await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions`, route => { expect(route.request().postDataJSON().kind).toBe('image'); cropped = true; return route.fulfill({ json: { job_id: 'crop-job' } }); }); await page.route('**/api/jobs/crop-job', route => route.fulfill({ json: { done: true } })); await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#app-skeleton', { state: 'detached' }); await navTo(page, 's-magazine'); await page.locator('#mag-library-list .mag-card').first().click(); await page.getByRole('button', { name: 'Review', exact: true }).click(); await page.getByRole('button', { name: 'Draw image', exact: true }).click(); await page.locator('#mag-review-page-wrap').evaluate(el => { el.style.width = '400px'; el.scrollIntoView(); }); const wrap = await page.locator('#mag-review-page-wrap').boundingBox(); await page.mouse.move(wrap.x + wrap.width * 40 / 595, wrap.y + wrap.height * 110 / 842); await page.mouse.down(); await page.mouse.move(wrap.x + wrap.width * 260 / 595, wrap.y + wrap.height * 460 / 842, { steps: 5 }); await page.mouse.up(); await expect(page.locator('input[name="mag-draw-kind"][value="image"]')).toBeChecked(); await expect(page.locator('#mag-draw-text')).toBeHidden(); await page.getByRole('button', { name: 'Add region', exact: true }).click(); await expect(page.locator('[data-block-row="manual-chart"]')).toContainText('Shown during playback'); await expect(page.locator('#mag-review input[data-block="b1"]')).toBeChecked(); await expect(page.locator('#mag-review input[data-block="b2"]')).not.toBeChecked(); await expect(page.locator('#mag-review input[data-block="b3"]')).not.toBeChecked(); const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001')); await page.getByRole('button', { name: 'Approve', exact: true }).click(); expect((await request).postDataJSON()).toMatchObject({ images: ['manual-chart'], blocks: ['b1'] }); });