import assert from "node:assert/strict"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; // Real extension toast on live YouTube, with controlled progress messages. // Settings use their real active-job renderer and the existing browser-API fixture. const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const artifacts = path.join(root, "test-artifacts"); await fs.mkdir(artifacts, { recursive: true }); const profile = await fs.mkdtemp(path.join(os.tmpdir(), "ytdl-motion-test-")); const context = await chromium.launchPersistentContext(profile, { headless: true, viewport: { width: 1280, height: 900 }, reducedMotion: "no-preference", executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", ignoreDefaultArgs: ["--disable-extensions"], args: ["--enable-unsafe-extension-debugging", "--mute-audio"], }); try { const cdp = await context.browser().newBrowserCDPSession(); await cdp.send("Extensions.loadUnpacked", { path: path.join(root, "extension") }); const worker = context.serviceWorkers()[0] || await context.waitForEvent("serviceworker"); const page = await context.newPage(); await page.goto("https://www.youtube.com/results?search_query=subnautica+shorts", { waitUntil: "domcontentloaded", timeout: 60000 }); const consent = page.getByRole("button", { name: /Reject all|Отклонить все/i }).first(); if (await consent.isVisible()) await consent.click(); await page.waitForTimeout(700); const tabId = await worker.evaluate(async url => (await chrome.tabs.query({})).find(tab => tab.url === url).id, page.url()); await context.route("https://ui.ytdl.test/**", async route => { const name = new URL(route.request().url()).pathname.slice(1); if (!["downloader.html", "styles.css", "downloader.js", "onboarding.js", "ui-connection.js", "theme-init.js", "shared.js", "session.js"].includes(name)) return route.abort(); await route.fulfill({ body: await fs.readFile(path.join(root, "extension", name)), contentType: name.endsWith(".html") ? "text/html" : name.endsWith(".css") ? "text/css" : "application/javascript" }); }); const menu = await context.newPage(); await menu.addInitScript({ path: path.join(root, "tests/ui-fixture.js") }); const errors = []; menu.on("pageerror", error => errors.push(error.message)); await menu.goto("https://ui.ytdl.test/downloader.html?theme=dark"); await menu.locator('#helper-panel[data-state="running"]').waitFor(); const jobId = "motion-preview"; const emit = async (values = {}) => { const job = { id: jobId, title: "Проверка анимации прогресса", status: "running", stage: "downloading", track: "video", percent: 25, downloaded: 123.4 * 1048576, total: 850 * 1048576, speed: 41.5 * 1048576, ...values }; await worker.evaluate(async ({ tabId, job }) => { await chrome.tabs.sendMessage(tabId, { type: "JOB_PROGRESS", job }).catch(() => {}); }, { tabId, job }); await menu.evaluate(job => window.__emitJob(job), job); await page.locator(`#ytdl-notifications [data-id="${jobId}"]`).waitFor(); }; const toastFill = page.locator(`#ytdl-notifications [data-id="${jobId}"] .fill`); const historyFill = menu.locator(`[data-job-id="${jobId}"] .progress-fill`); const toastTransfer = page.locator(`#ytdl-notifications [data-id="${jobId}"] .transfer`); const historyTransfer = menu.locator(`[data-job-id="${jobId}"] .job-transfer`); async function checkSize(values, expected, expectedHistory = expected) { await emit(values); await page.waitForFunction(({ id, text }) => document.querySelector('#ytdl-notifications').shadowRoot.querySelector(`[data-id="${id}"] .transfer`).textContent === text, { id: jobId, text: expected }); for (const [label, text] of [[toastTransfer, expected], ...(values.status && values.status !== "running" ? [] : [[historyTransfer, expectedHistory]])]) { assert.equal(await label.textContent(), text); assert.equal(await label.evaluate(node => node.hidden), !text); if (text) assert.equal(await label.evaluate(node => node.scrollWidth <= node.clientWidth), true, "Byte count must fit without truncation"); } } async function sample(fill, indeterminate) { await fill.page().bringToFront(); return fill.evaluate(async (node, waiting) => { const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); await frame(); void getComputedStyle(node, '::after').animationName; const scope = node.getRootNode(); // Chrome exposes pseudo-element animations through the ShadowRoot timeline. const animation = scope.getAnimations().find(item => item.effect?.target === node && item instanceof CSSAnimation); if (!animation) throw new Error("Running progress must actually animate: " + JSON.stringify({ node: node.outerHTML, reduced: matchMedia('(prefers-reduced-motion: reduce)').matches, pseudo: { animation: getComputedStyle(node, '::after').animationName, duration:getComputedStyle(node, '::after').animationDuration, content: getComputedStyle(node, '::after').content }, animations: node.getAnimations({subtree:true}).map(item => ({name:item.animationName,type:item.constructor.name,css:item instanceof CSSAnimation})), frames: [...(node.getRootNode().querySelector('style')?.sheet?.cssRules || [])].filter(rule=>rule.type===CSSRule.KEYFRAMES_RULE).map(rule=>rule.name), rootAnimations: node.getRootNode().getAnimations?.().map(item=>({name:item.animationName,target:item.effect?.target?.className})), geometry:node.getBoundingClientRect().toJSON(),visibility:document.visibilityState, })); animation.pause(); const duration = animation.effect.getTiming().duration; const track = node.parentElement, positions = []; for (const phase of [0, .125, .25, .375, .5, .625, .75, .875, .999]) { animation.currentTime = duration * phase; await frame(); const bar = node.getBoundingClientRect(), bounds = track.getBoundingClientRect(); if (waiting && (bar.left < bounds.left - .2 || bar.right > bounds.right + .2)) throw new Error("Waiting segment escaped the track"); // A pseudo-element painted outside its fill also intercepts this hit test. // Probe the unfilled track, rather than merely checking CSS property names. for (let x = bounds.left + 2; x < bounds.right - 2; x += 2) { if (x >= bar.left - 1 && x <= bar.right + 1) continue; const hit = scope.elementFromPoint(x, bounds.top + bounds.height / 2); if (hit === node) throw new Error("Moving highlight leaked into the unfilled track"); } positions.push({ phase, left: bar.left - bounds.left, right: bar.right - bounds.left, trackWidth: bounds.width }); } if (waiting) { const travel = Math.max(...positions.map(item => item.left)) - Math.min(...positions.map(item => item.left)); if (travel < positions[0].trackWidth * .5) throw new Error("Waiting animation barely moves"); for (let i = 1; i < 5; i++) if (positions[i].left < positions[i - 1].left - .2) throw new Error("Waiting motion changes direction too early"); for (let i = 5; i < positions.length; i++) if (positions[i].left > positions[i - 1].left + .2) throw new Error("Waiting motion does not return smoothly"); } animation.currentTime = duration * (waiting ? .5 : .75); await frame(); return positions; }, indeterminate); } const results = []; await emit(); await page.waitForTimeout(400); await page.evaluate(id => { const toast = document.querySelector('#ytdl-notifications').shadowRoot.querySelector(`[data-id="${id}"]`); window.__unchangedReadoutMutations = []; window.__readoutObserver = new MutationObserver(records => window.__unchangedReadoutMutations.push(...records.map(record => record.target.parentElement?.className || record.target.className))); for (const selector of ['.title', '.stage', '.icon', '.label', '.action']) window.__readoutObserver.observe(toast.querySelector(selector), { childList: true, characterData: true, subtree: true }); }, jobId); await menu.evaluate(() => { window.__cardMoves = 0; window.__cardObserver = new MutationObserver(records => { window.__cardMoves += records.length; }); window.__cardObserver.observe(document.querySelector('#active-jobs'), { childList: true }); }); for (let index = 0; index < 10; index++) await emit({ downloaded: (124 + index) * 1048576 }); const unchangedReadoutMutations = await page.evaluate(() => { window.__readoutObserver.disconnect(); return window.__unchangedReadoutMutations; }); assert.deepEqual(unchangedReadoutMutations, [], 'Unchanged progress labels must not be rebuilt for each byte-count event'); assert.equal(await menu.evaluate(() => { window.__cardObserver.disconnect(); return window.__cardMoves; }), 0, 'A progress update must not detach and reinsert the active card'); const readout = page.locator(`#ytdl-notifications [data-id="${jobId}"] .label`); await readout.evaluate(node => { window.__progressLabelTextNode = node.firstChild; }); await emit({ percent: 26, speed: 4.2 * 1048576 }); assert.equal(await readout.textContent(), '26% · 4.2 МБ/с'); assert.equal(await readout.evaluate(node => node.firstChild === window.__progressLabelTextNode), true, 'Changing measurements must update the existing text node'); await emit({ speed: 0 }); assert.equal(await readout.textContent(), '25% · 0.0 МБ/с', 'An actual zero-speed measurement must remain visible'); for (const width of [1280, 360]) { await page.setViewportSize({ width, height: 900 }); await menu.setViewportSize({ width: Math.min(width, 520), height: 900 }); await checkSize({ downloaded: 0 }, "Скачано 0,0 МБ из 850,0 МБ"); await checkSize({}, "Скачано 123,4 МБ из 850,0 МБ"); await checkSize({ track: "audio", downloaded: 12.8 * 1048576, total: 24.4 * 1048576 }, "Скачано 12,8 МБ из 24,4 МБ"); await checkSize({ downloaded: 12.8 * 1048576, total: 0 }, "Скачано 12,8 МБ"); await checkSize({ downloaded: null, total: null }, ""); await checkSize({ stage: "encoding", engine: "nvenc" }, ""); assert.equal(await page.locator(`#ytdl-notifications [data-id="${jobId}"] .stage`).textContent(), "Перекодирование · NVIDIA NVENC"); await checkSize({ stage: "remuxing", engine: "copy", percent: 35, processedSeconds: 84, durationSeconds: 240, written: 1234.5 * 1048576, speed: "120x", eta: 1 }, "Обработано 1:24 из 4:00 · Записано 1 234,5 МБ", "Обработано 1:24 из 4:00 · Записано 1 234,5 МБ · осталось ≈ 1 с"); assert.equal(await page.locator(`#ytdl-notifications [data-id="${jobId}"] .stage`).textContent(), "Подготовка файла · без перекодирования"); await checkSize({ stage: "audio", engine: "cpu", percent: 35, processedSeconds: 84, durationSeconds: 240, written: 1234.5 * 1048576, speed: "48x" }, "Обработано 1:24 из 4:00 · Записано 1 234,5 МБ"); assert.equal(await page.locator(`#ytdl-notifications [data-id="${jobId}"] .stage`).textContent(), "Обработка аудио · CPU"); await checkSize({ stage: "finalizing", engine: "copy", percent: 99, processedSeconds: 240, durationSeconds: 240, written: 1234.5 * 1048576, speed: "", eta: null, message: "Финализация файла" }, "Обработано 4:00 из 4:00 · Записано 1 234,5 МБ"); assert.equal(await page.locator(`#ytdl-notifications [data-id="${jobId}"] .stage`).textContent(), "Финализация файла"); await checkSize({ stage: "merging", percent: 35, processedSeconds: 84, durationSeconds: 240, written: 1234.5 * 1048576, speed: "38.6x", eta: 4.1 }, "Обработано 1:24 из 4:00 · Записано 1 234,5 МБ", "Обработано 1:24 из 4:00 · Записано 1 234,5 МБ · осталось ≈ 5 с"); await page.locator(`#ytdl-notifications [data-id="${jobId}"]`).screenshot({ path: path.join(artifacts, `progress-merging-${width}.png`) }); await checkSize({ stage: "saving", copied: 256 * 1048576, total: 1024 * 1048576, percent: 25, speed: null }, "Сохранено 256,0 МБ из 1 024,0 МБ"); await emit(); await page.waitForTimeout(400); results.push({ width, state: "25%", toast: await sample(toastFill, false), history: await sample(historyFill, false) }); await emit({ percent: 90 }); await page.waitForTimeout(350); // Enter waiting from a wide filled bar to catch an unwanted width transition. await emit({ percent: null, stage: "preparing" }); results.push({ width, state: "waiting", toast: await sample(toastFill, true), history: await sample(historyFill, true) }); await page.locator(`#ytdl-notifications [data-id="${jobId}"]`).screenshot({ path: path.join(artifacts, `progress-waiting-${width}.png`) }); await emit({ percent: 25 }); await page.waitForTimeout(350); results.push({ width, state: "resumed", toast: await sample(toastFill, false), history: await sample(historyFill, false) }); await page.locator(`#ytdl-notifications [data-id="${jobId}"]`).screenshot({ path: path.join(artifacts, `progress-downloading-${width}.png`) }); } for (const status of ["complete", "error", "cancelled"]) { await checkSize({ status, stage: status, percent: status === "complete" ? 100 : 25, result: status === "complete" ? { filename: "Проверка.mp4", size: 1024.5 * 1048576 } : undefined }, status === "complete" ? "Размер файла: 1 024,5 МБ" : ""); await page.waitForTimeout(350); assert.equal(await historyFill.count(), 0, `${status}: terminal jobs move out of settings`); assert.equal(await toastFill.evaluate(node => node.getRootNode().getAnimations().filter(item => item.effect?.target === node && item instanceof CSSAnimation).length), 0, `${status}: moving effects must stop`); } await page.emulateMedia({ reducedMotion: "reduce" }); await menu.emulateMedia({ reducedMotion: "reduce" }); for (const percent of [25, null]) { await emit({ percent }); await page.waitForTimeout(30); for (const fill of [toastFill, historyFill]) assert.equal(await fill.evaluate(node => node.getRootNode().getAnimations().filter(item => item.effect?.target === node).length), 0, "Reduced motion must disable moving effects"); } assert.deepEqual(errors, []); await fs.writeFile(path.join(artifacts, "progress-motion-result.json"), JSON.stringify({ version: JSON.parse(await fs.readFile(path.join(root, "extension/manifest.json"))).version, actualYouTubeToast: true, stages: results, terminalEffectsStopped: true, reducedMotion: true }, null, 2)); console.log("Progress motion passed: actual Edge/YouTube toast, stable readouts and history cards, zero-speed display, highlight clipping, bounded waiting motion, resume, narrow layout, terminal states and reduced motion."); } finally { await context.close(); if (path.dirname(path.resolve(profile)) === path.resolve(os.tmpdir()) && path.basename(profile).startsWith("ytdl-motion-test-")) await fs.rm(profile, { recursive: true, force: true }); }