168 lines
13 KiB
JavaScript
168 lines
13 KiB
JavaScript
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] |');
|
|
});
|
|
test('editor loads the whole audio into the browser once and reuses it', async ({ page }) => {
|
|
const wav = Buffer.alloc(44 + 16000); // 1 s of silence, 8 kHz 16-bit mono
|
|
wav.write('RIFF', 0); wav.writeUInt32LE(36 + 16000, 4); wav.write('WAVEfmt ', 8); wav.writeUInt32LE(16, 16);
|
|
wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28);
|
|
wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34); wav.write('data', 36); wav.writeUInt32LE(16000, 40);
|
|
let hits = 0;
|
|
await fixture(page);
|
|
await page.route('**/api/media/' + id + '?a=1', route => { hits++; route.fulfill({ contentType: 'audio/wav', body: wav }); });
|
|
await page.locator('[data-open="' + id + '"]').first().click();
|
|
await expect(page.locator('#dur')).toHaveText('0:01');
|
|
expect(hits).toBe(1);
|
|
await page.reload(); await page.locator('[data-open="' + id + '"]').first().click();
|
|
await expect(page.locator('#dur')).toHaveText('0:01');
|
|
expect(hits).toBe(1);
|
|
});
|