const { test, expect } = require('@playwright/test'); const id = '0gfX0dFLaBc'; async function fixture(page) { const state = { job: null, starts: 0, saves: [] }; await page.route('**/api/**', async route => { const req = route.request(), url = new URL(req.url()); let body = { ok: true }; if (url.pathname === '/api/admin/me') body = { enabled: true, admin: true }; else if (url.pathname === '/api/admin/media') body = { ok: true, media: [{ id, title: 'Because You are God', channel: 'Cathedral of Praise Worship', lyricsLines: 47 }] }; else if (url.pathname === '/api/uploads') body.uploads = []; else if (url.pathname === '/api/admin/flags') body = { ok: true, flags: [], open: 0 }; else if (url.pathname === '/api/notes/' + id) body = { lyrics: { rev: 8, data: { lines: Array.from({ length: 47 }, (_, i) => ({ t: i * 5, text: 'Original cue ' + i, kind: 'line' })), tags: ['Key G'], offset: 0 } } }; else if (url.pathname === '/api/streams') body = { data: { meta: { title: 'Because You are God', duration: 240 }, qualities: [] } }; else if (url.pathname === '/api/admin/transcriptions/' + id) { if (req.method() === 'POST') { state.starts++; state.job = { id: 'job', videoId: id, status: 'queued', baseRev: 8 }; } body = { ok: true, job: state.job }; } else if (url.pathname === '/api/notes/' + id + '/lyrics' && req.method() === 'PUT') { state.saves.push(req.postDataJSON()); body.rev = 9; } return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }); }); await page.goto('/admin.html#editor'); await expect(page.locator('[data-open="' + id + '"]').first()).toBeVisible(); return state; } function complete(state) { state.job = { ...state.job, status: 'complete', result: { lines: [{ t: 1, text: 'Because You are God', kind: 'line' }, { t: 4, text: 'You can do anything', kind: 'line' }], tags: ['auto-transcribed (whisper)'], offset: 0 } }; } test('row and editor buttons queue one job and require review before saving', async ({ page }) => { const state = await fixture(page); await page.locator('[data-whisper]').click(); await expect(page.locator('#whisperBtn')).toBeDisabled(); expect(state.starts).toBe(1); await expect(page.locator('.ln')).toHaveCount(47); complete(state); await expect(page.locator('#whisperUse')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.ln')).toHaveCount(47); expect(state.saves).toHaveLength(0); await page.locator('#whisperUse').click(); await expect(page.locator('.ln')).toHaveCount(2); expect(state.saves).toHaveLength(0); await page.locator('#bUndo').click(); await expect(page.locator('.ln')).toHaveCount(47); await page.locator('#bRedo').click(); await expect(page.locator('.ln')).toHaveCount(2); await page.locator('#save').click(); await expect.poll(() => state.saves.length).toBe(1); expect(state.saves[0].baseRev).toBe(8); expect(state.saves[0].data.lines[0].text).toBe('Because You are God'); }); test('editor button keeps unsaved words until the draft is explicitly applied', async ({ page }) => { const state = await fixture(page); await page.locator('[data-open="' + id + '"]').first().click(); await page.locator('.ln-text').first().fill('My correction'); await page.locator('#whisperBtn').click(); complete(state); await expect(page.locator('#whisperUse')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.ln-text').first()).toHaveValue('My correction'); page.once('dialog', dialog => dialog.dismiss()); await page.locator('#whisperUse').click(); await expect(page.locator('.ln-text').first()).toHaveValue('My correction'); expect(state.saves).toHaveLength(0); }); test('mobile buttons remain reachable and failed requests allow retry', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await fixture(page); const button = page.locator('[data-whisper]'); await expect(button).toBeVisible(); const box = await button.boundingBox(); expect(box.height).toBeGreaterThanOrEqual(44); expect(box.x + box.width).toBeLessThanOrEqual(390); await page.route('**/api/admin/transcriptions/' + id, route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ ok: false, error: 'Lyrics worker is not configured.' }) })); await button.click(); await expect(page.locator('#msg')).toContainText('not configured'); await expect(page.locator('#whisperBtn')).toBeEnabled(); }); for (const width of [390, 1440]) { test(`grouped cue keeps one time through text editing and save at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); const state = await fixture(page); await page.locator('[data-open="' + id + '"]').first().click(); const input = page.locator('.ln-text').first(); await input.fill('Because You are God'); await input.press('End'); if (width === 390) await page.locator('.ln').first().locator('[data-act="break"]').click(); else await input.press('Shift+Enter'); await input.press('ControlOrMeta+End'); await input.pressSequentially('You can do anything'); await expect(input).toHaveValue('Because You are God\nYou can do anything'); await expect(page.locator('.ln')).toHaveCount(47); await page.locator('#bTools').click(); await page.locator('#rawBtn').click(); await expect(page.locator('#raw')).toContainText(''); expect(await page.locator('#raw').inputValue()).toContain('| You can do anything'); await page.locator('#rawBtn').click(); await expect(page.locator('.ln-text').first()).toHaveValue('Because You are God\nYou can do anything'); await page.locator('#save').click(); await expect.poll(() => state.saves.length).toBe(1); expect(state.saves[0].data.lines[0]).toEqual({ t: 0, text: 'Because You are God\nYou can do anything', kind: 'line' }); }); } const { openClassic } = require('./helpers/classic-fixture'); for (const width of [390, 1440]) { test(`service mode highlights the whole grouped cue with a tight internal gap at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); await openClassic(page); await page.route('**/api/notes/' + id, route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, lyrics: { rev: 1, data: { lines: [{ t: 0, text: 'Because You are God\nYou can do anything', kind: 'line' }, { t: 30, text: 'Next lyric', kind: 'line' }], tags: [], offset: 0 } } }) })); await page.evaluate(async id => { current = { meta: { id, title: 'Because You are God' }, qualities: [], audioUrl: '' }; await Notes.load(current.meta); enterServiceMode(); setServiceView('lyrics'); Notes.tick(0, true); }, id); const cue = page.locator('#serviceLyricsList .sl-line').first(); await expect(cue).toHaveClass(/active/); await expect(cue).toHaveText('Because You are God\nYou can do anything'); await expect(page.locator('#serviceLyricsList .sl-line')).toHaveCount(2); await page.waitForTimeout(450); const metrics = await cue.evaluate(el => { const style = getComputedStyle(el), next = el.nextElementSibling; return { space: style.whiteSpace, leading: parseFloat(style.lineHeight) - parseFloat(style.fontSize), gap: next.getBoundingClientRect().top - el.getBoundingClientRect().bottom, height: el.clientHeight, lineHeight: parseFloat(style.lineHeight) }; }); expect(metrics.space).toBe('pre-line'); expect(metrics.gap).toBeGreaterThan(metrics.leading); expect(metrics.height).toBeGreaterThan(metrics.lineHeight * 1.8); }); } for (const width of [320, 390, 820, 1440]) { test(`admin analytics exposes storage and collector controls at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); await fixture(page); let request = null, cancelled = false; await page.route('**/api/admin/analytics', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, media: [{ status: 'ready', count: 2, bytes: 1048576, seconds: 3600 }], uploads: [{ count: 1, bytes: 1024 }], catalog: { videos: 15, bytes: 3000 }, details: { videos: 4, bytes: 10000 }, thumbnails: { saved: 12, pending: 3, bytes: 1000 }, listening: { plays: 47, videos: 2 }, topPlayed: [{ title: 'Because You are God', plays: 47 }], sources: [{ source: 'search', videos: 15, discoveries: 23 }], volumes: [{ label: 'Video cache', device: '1', total: 1000000000, free: 500000000 }] }) })); await page.route('**/api/admin/metadata?**', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, total: 1, videos: [{ id, title: 'Because You are God', channel: 'Cathedral of Praise Worship', enriched: true, thumbnailBytes: 1024, duration: 240 }] }) })); await page.route('**/api/admin/metadata/' + id, route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, metadata: { id, description: 'Stored descriptive metadata', tags: ['worship'] } }) })); await page.route('**/api/admin/collections', route => { if (route.request().method() === 'POST') request = route.request().postDataJSON(); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, id: 'job', jobs: request ? [{ id: 'job', query: request.query, maxVideos: request.maxVideos, depth: request.depth, status: cancelled ? 'cancelled' : 'queued', collected: 0, enriched: 0, failed: 0, searches: 0 }] : [] }) }); }); await page.route('**/api/admin/collections/job/cancel', route => { cancelled = true; return route.fulfill({ contentType: 'application/json', body: '{"ok":true}' }); }); await page.locator('[data-tab="analytics"]:visible').click(); await expect(page.locator('#analyticsMetrics .metric')).toHaveCount(8); await expect(page.locator('#analyticsVolumes')).toContainText('available'); await page.locator('#collectorQuery').fill('Cathedral worship'); await page.locator('#collectorLimit').fill('45'); await page.locator('#collectorDepth').selectOption('2'); await page.locator('#collectorStart').click(); await expect(page.locator('#collectorStatus')).toContainText('queued'); expect(request).toEqual({ query: 'Cathedral worship', maxVideos: 45, depth: 2 }); await page.getByRole('button', { name: 'Stop collection' }).click(); await expect(page.locator('#collectionJobs')).toContainText('cancelled'); await page.locator('[data-metadata-id]').click(); await expect(page.locator('#metadataDetail')).toContainText('Stored descriptive metadata'); const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1); expect(overflow).toBe(false); await page.locator('#analyticsRefresh').scrollIntoViewIfNeeded(); await page.screenshot({ path: `/tmp/ytplayer-admin-analytics-${width}.png`, fullPage: width >= 1000 }); if (width < 1000) { await page.locator('#collectorForm').evaluate(el => el.scrollIntoView({ block: 'center' })); await page.screenshot({ path: `/tmp/ytplayer-admin-collector-${width}.png` }); const reached = await page.locator('#collectorStart').evaluate(el => { const r = el.getBoundingClientRect(); return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)); }); expect(reached).toBe(true); } }); } test('player lyric editor groups Shift+Enter words and stamps their shared cue', async ({ page }) => { await openClassic(page); await page.route('**/api/notes/' + id, route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, lyrics: { rev: 1, data: { lines: [{ t: 12, text: 'Because You are God', kind: 'line' }, { t: 30, text: 'Next lyric', kind: 'line' }], tags: [], offset: 0 } } }) })); await page.evaluate(async id => { data.profile = { name: 'fixture' }; current = { meta: { id, title: 'Song' } }; await Notes.load(current.meta); document.getElementById('lyricsEditBtn').click(); }, id); const text = page.locator('.lyr-editor-text'); await expect(text).toBeVisible(); await text.evaluate(el => { el.focus(); const pos = el.value.indexOf('\n'); el.setSelectionRange(pos, pos); }); await text.press('Shift+Enter'); await text.pressSequentially('You can do anything'); expect(await text.inputValue()).toContain('Because You are God\n| You can do anything'); await page.locator('.lyr-editor [data-act="stamp"]').click(); const document = await text.inputValue(); expect(document).toContain('[0:00.00] Because You are God\n| You can do anything'); expect(document).not.toContain('[0:00.00] |'); });