Files
Twitch-VOD-Manager/scripts/smoke-test-queue-cards.js
2026-09-07 03:04:12 +02:00

253 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { _electron: electron } = require('playwright');
const { createE2eEnvironment, getElectronLaunchOptions, verifyE2eIsolation, installOfflineFixtures, cleanupE2eEnvironment } = require('./e2e-test-environment');
async function main() {
const environment = createE2eEnvironment('queue-cards');
const artifacts = path.resolve(__dirname, '../tmp_queue-card-artifacts');
fs.mkdirSync(artifacts, { recursive: true });
let app;
try {
app = await electron.launch(getElectronLaunchOptions(environment));
const win = await app.firstWindow();
await verifyE2eIsolation(app, win, environment);
await installOfflineFixtures(app);
const errors = [];
win.on('pageerror', (error) => errors.push(String(error)));
await win.waitForFunction(() => typeof window.showTab === 'function' && typeof window.changeLanguage === 'function');
await win.waitForFunction((version) => appVersion === version, require('../package.json').version);
const fixtures = await win.evaluate(() => {
showTab('vods');
const panel = document.querySelector('[data-context-for="vods"]');
panel.dataset.vodsLayout = 'tabs';
panel.dataset.vodsWorkspace = 'queue';
queue = [
{ id: 'pending', status: 'pending', title: 'Ein langer Streamtitel mit gut lesbaren Download-Details und einer zweiten Zeile', progress: 0, url: 'https://www.twitch.tv/videos/2863358704', streamer: 'xrohat' },
{ id: 'running', status: 'downloading', title: 'Sommerstream am See gemeinsam unterwegs', progress: 42, progressStatus: 'Video wird heruntergeladen', speed: '12.5 MB/s', eta: '08:24' },
{ id: 'paused', status: 'paused', title: 'Community-Abend mit Freunden', progress: 23 },
{ id: 'error', status: 'error', title: 'Ein weiterer langer Streamtitel mit einer Fehlermeldung', progress: 10, last_error: 'Die Verbindung wurde unterbrochen. Bitte erneut versuchen.' },
{ id: 'completed', status: 'completed', title: 'Highlights vom Wochenende', progress: 100, outputFiles: ['C:\\fixture\\highlights.mp4'] },
{ id: 'live', status: 'downloading', title: 'Live aus dem Studio', progress: 0, isLive: true, recordingHealth: 'ok', progressStatus: 'Live-Aufnahme läuft' }
].map((item) => ({ url: `https://example.invalid/${item.id}`, date: '2026-09-06T10:00:00Z', streamer: 'Beispielkanal', duration_str: '2h 34m', ...item }));
renderQueue();
return queue;
});
await app.evaluate(({ ipcMain }, items) => {
ipcMain.removeHandler('get-queue');
ipcMain.handle('get-queue', async () => items);
}, fixtures);
await win.evaluate(async () => {
await syncQueueAndDownloadState();
clearTimeout(queueSyncTimer);
queueSyncTimer = null;
});
assert.equal(await win.locator('#queueList .queue-item').count(), fixtures.length, 'Queue fixtures survive background synchronization');
assert.equal(await win.locator('[data-id="pending"] .queue-streamer-name').textContent(), 'xrohat', 'Unknown display names fall back to the login');
await win.evaluate(() => rememberStreamerDisplayName('XROHAT', 'xRohat'));
assert.equal(await win.locator('[data-id="pending"] .queue-streamer-name').textContent(), 'xRohat', 'Loaded display casing refreshes existing queue cards');
assert.equal(await win.evaluate(() => queue.find((item) => item.id === 'pending').streamer), 'xrohat', 'Display casing preserves the stored login');
for (const theme of ['twitch', 'light']) {
for (const language of ['de', 'en']) {
await win.setViewportSize({ width: 1280, height: 900 });
await win.evaluate(({ theme, language }) => {
document.body.className = `theme-${theme}`;
changeLanguage(language);
}, { theme, language });
const layout = await win.locator('#queueList').evaluate((list) => [...list.querySelectorAll('.queue-item')].map((item) => {
const rect = (selector) => item.querySelector(selector).getBoundingClientRect();
const date = rect('.queue-date');
const bar = rect('.queue-progress-wrap');
const status = rect('.queue-status-badge');
const toggle = rect('.queue-details-toggle');
const remove = rect('.remove');
return {
id: item.dataset.id,
dateBelow: date.top >= bar.bottom,
dateLeft: date.left >= toggle.right && date.right < status.left,
statusRight: Math.abs(status.right - bar.right) <= 1,
dateSize: parseFloat(getComputedStyle(item.querySelector('.queue-date')).fontSize),
removeSize: Math.min(remove.width, remove.height),
overflow: item.scrollWidth - item.clientWidth,
tinyText: [...item.querySelectorAll('*')].filter((element) => element.textContent.trim() && element.getBoundingClientRect().height > 0 && parseFloat(getComputedStyle(element).fontSize) < 10).length,
};
}));
for (const card of layout) {
assert(card.dateBelow && card.dateLeft && card.statusRight, `Footer placement: ${JSON.stringify(card)}`);
assert(card.dateSize >= 12 && card.removeSize >= 32 && card.tinyText === 0, `Readability: ${JSON.stringify(card)}`);
assert(card.overflow <= 1, `Card overflow: ${JSON.stringify(card)}`);
}
await win.locator('.queue-section').screenshot({ path: path.join(artifacts, `queue-${theme}-${language}.png`) });
for (const size of ['500.0 MB', '1003.0 MB', '1.35 GB']) {
const transfer = await win.evaluate(({ size, language }) => {
const item = queue.find((candidate) => candidate.id === 'running');
item.progress = 32.2;
item.progressStatus = `${size} ${language === 'de' ? 'heruntergeladen' : 'downloaded'}`;
item.speed = '23.1 MB/s';
item.eta = '12m 34s';
updateQueueItemProgress({ id: item.id });
const row = document.querySelector('[data-id="running"] .queue-progress-info');
const status = row.querySelector('.queue-progress-status');
const metrics = row.querySelector('.queue-progress-metrics');
return {
height: row.getBoundingClientRect().height,
sameLine: status.getBoundingClientRect().top === metrics.getBoundingClientRect().top,
statusVisible: status.scrollWidth <= status.clientWidth,
metricsVisible: metrics.scrollWidth <= metrics.clientWidth,
status: status.textContent,
tooltip: status.title,
};
}, { size, language });
assert(transfer.height <= 18 && transfer.sameLine && transfer.statusVisible && transfer.metricsVisible && transfer.status === size && transfer.tooltip.includes(size), `Transfer line (${theme}/${language}): ${JSON.stringify(transfer)}`);
}
await win.locator('[data-id="running"]').screenshot({ path: path.join(artifacts, `transfer-${theme}-${language}.png`) });
const expansionLayout = await win.locator('#queueList').evaluate(async (list) => {
const items = [...list.querySelectorAll('.queue-item')];
const originalStyles = [list, ...items].map((element) => element.getAttribute('style'));
const item = items[0];
const details = item.querySelector('.queue-details');
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const measure = () => {
const title = item.querySelector('.title').getBoundingClientRect();
return { left: title.left, width: title.width, height: title.height, overflowing: list.scrollHeight > list.clientHeight };
};
try {
for (const other of items.slice(2)) other.style.display = 'none';
list.style.flex = '0 0 auto';
list.style.height = `${items.slice(0, 2).reduce((height, card) => height + card.getBoundingClientRect().height + parseFloat(getComputedStyle(card).marginBottom), 0) + 20}px`;
const before = measure();
const samples = [];
const animate = async () => {
toggleQueueDetails(item.dataset.id);
await frame();
await frame();
do {
samples.push(measure());
await frame();
} while (details.getAnimations().length);
return measure();
};
const expanded = await animate();
const url = item.querySelector('.queue-url-copy');
const urlLabel = item.querySelector('.queue-url-row .queue-detail-label');
const urlLayout = {
belowLabel: url.getBoundingClientRect().top >= urlLabel.getBoundingClientRect().bottom,
singleLine: getComputedStyle(url).whiteSpace === 'nowrap',
fullyVisible: url.scrollWidth <= url.clientWidth,
};
const collapsed = await animate();
return { before, expanded, collapsed, samples, urlLayout };
} finally {
[list, ...items].forEach((element, index) => {
if (originalStyles[index] === null) element.removeAttribute('style');
else element.setAttribute('style', originalStyles[index]);
});
}
});
assert(!expansionLayout.before.overflowing && expansionLayout.expanded.overflowing && !expansionLayout.collapsed.overflowing, `Expansion fixture crosses the scrollbar threshold (${theme}/${language}): ${JSON.stringify({ before: expansionLayout.before, expanded: expansionLayout.expanded, collapsed: expansionLayout.collapsed })}`);
assert(Object.values(expansionLayout.urlLayout).every(Boolean), `URL stays fully visible below its label (${theme}/${language}): ${JSON.stringify(expansionLayout.urlLayout)}`);
for (const sample of [...expansionLayout.samples, expansionLayout.expanded, expansionLayout.collapsed]) {
for (const dimension of ['left', 'width', 'height']) {
assert(Math.abs(sample[dimension] - expansionLayout.before[dimension]) <= 0.5, `Title ${dimension} shifts during expansion (${theme}/${language})`);
}
}
}
}
const card = win.locator('.queue-item[data-id="pending"]');
const toggle = card.locator('[data-queue-action="details"]');
for (const reducedMotion of ['no-preference', 'reduce']) {
await win.emulateMedia({ reducedMotion });
const motion = await card.evaluate(async (item) => {
const details = item.querySelector('.queue-details');
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const sample = async () => {
const animations = details.getAnimations();
const slide = animations.find((animation) => animation.transitionProperty === 'grid-template-rows');
if (!slide) throw new Error('Queue details have no height transition');
for (const animation of animations) animation.currentTime = Number(animation.effect.getTiming().duration) / 2;
const middle = details.getBoundingClientRect().height;
for (const animation of animations) animation.finish();
await frame();
return { middle, end: details.getBoundingClientRect().height };
};
const collapsed = details.getBoundingClientRect().height;
toggleQueueDetails(item.dataset.id);
const opening = await sample();
toggleQueueDetails(item.dataset.id);
const closing = await sample();
return { collapsed, opening, closing, inert: details.inert };
});
assert.equal(motion.collapsed, 0);
assert(motion.opening.middle > 0 && motion.opening.middle < motion.opening.end, 'Details slide open');
assert(motion.closing.middle > 0 && motion.closing.middle < motion.opening.end, 'Details slide closed');
assert.equal(motion.closing.end, 0);
assert(motion.inert, 'Collapsed details cannot receive keyboard input');
}
for (const selector of ['.title', '.queue-date', '.queue-progress-wrap', '.queue-status-label']) {
await card.locator(selector).dblclick();
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', `${selector} expands`);
await card.locator(selector).dblclick();
assert.equal(await toggle.getAttribute('aria-expanded'), 'false', `${selector} collapses`);
}
await card.dblclick({ position: { x: 4, y: 4 } });
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Card padding expands');
await toggle.click();
assert.equal(await toggle.getAttribute('aria-expanded'), 'false', 'Toggle collapses');
await toggle.dblclick();
assert.equal(await toggle.getAttribute('aria-expanded'), 'false', 'Both rapid toggle clicks are applied');
for (const detail of [3, 4, 5]) {
await toggle.dispatchEvent('click', { detail });
assert.equal(await toggle.getAttribute('aria-expanded'), String(detail % 2 === 1), 'Rapid repeated clicks are never suppressed');
}
await toggle.focus();
await win.keyboard.press('Space');
assert.equal(await toggle.getAttribute('aria-expanded'), 'false', 'Space collapses');
await win.keyboard.press('Enter');
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Enter expands');
assert(await toggle.evaluate((button) => document.activeElement === button), 'Toggle retains keyboard focus');
await win.evaluate(() => {
queue.find((item) => item.id === 'running').progress = 61;
renderQueue();
});
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Expansion survives queue rerender');
assert.equal(await win.locator('[data-id="running"] .queue-progress-wrap').getAttribute('aria-valuenow'), '61');
await win.evaluate(() => {
window.queueCopiedUrls = [];
Object.defineProperty(navigator.clipboard, 'writeText', {
configurable: true,
value: async (url) => { window.queueCopiedUrls.push(url); },
});
});
const urlButton = card.locator('.queue-url-copy');
await urlButton.click();
await urlButton.press('Enter');
await urlButton.press('Space');
assert.deepEqual(await win.evaluate(() => window.queueCopiedUrls), Array(3).fill(fixtures[0].url));
assert.equal(await toggle.getAttribute('aria-expanded'), 'true', 'Copying the URL keeps details open');
assert(await win.evaluate(() => document.body.innerText.includes(UI_TEXT.queue.ctxCopiedUrl)), 'Copy confirmation is visible');
for (const theme of ['twitch', 'light']) {
await win.evaluate((theme) => { document.body.className = `theme-${theme}`; }, theme);
await card.screenshot({ path: path.join(artifacts, `queue-url-${theme}.png`) });
}
await app.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('remove-from-queue');
ipcMain.handle('remove-from-queue', async (_event, id) => {
if (id !== 'pending') throw new Error('Unexpected removal target');
ipcMain.removeHandler('get-queue');
ipcMain.handle('get-queue', async () => []);
return [];
});
});
await card.locator('.remove svg path').click();
await win.waitForFunction(() => document.querySelectorAll('#queueList .queue-item').length === 0);
assert.deepEqual(errors, []);
console.log(JSON.stringify({ failures: [], themes: 2, languages: 2, states: 6, interactions: 'passed', artifacts }));
} finally {
if (app) await app.close();
cleanupE2eEnvironment(environment);
}
}
main().catch((error) => { console.error(error); process.exitCode = 1; });