Files
YouTubeDL/tests/progress-motion.test.mjs
DepressedCat 5f2aad00f4
Check extension and helper / check (push) Canceled after 0s
Publish YouTubeDL 1.4.5 and website on Gitea
2026-10-10 23:53:17 +03:00

177 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
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 });
}