Publish YouTubeDL 1.4.5 and website on Gitea
Check extension and helper / check (push) Canceled after 0s

This commit is contained in:
DepressedCat committed 2026-10-10 23:53:17 +03:00
commit 5f2aad00f4
97 files changed
+8451

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
"""On this NVIDIA machine: real AMF initialization failure and automatic CPU fallback."""
import contextlib
import io
import json
import subprocess
import sys
from pathlib import Path
PROJECT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT))
from host.media import capabilities, probe, tool
from host.worker import convert
hardware = capabilities()
if hardware["amf"]:
raise RuntimeError("This negative-path check is intended for a machine without AMD AMF.")
folder = PROJECT / "test-artifacts/media"; folder.mkdir(parents=True, exist_ok=True)
source = folder / "amd-fallback-input.mkv"
subprocess.run([tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=s=320x180:r=24", "-f", "lavfi", "-i", "sine=frequency=440", "-t", "0.4", "-c:v", "libx264", "-c:a", "aac", str(source)], check=True, capture_output=True)
media = probe(source)
options = {"mode": "video", "container": "mp4", "codec": "hevc", "engine": "auto", "preset": "fast", "bitrate": 192}
events = io.StringIO()
with contextlib.redirect_stdout(events):
plan = convert(source, folder / "amd-fallback-result.mp4", options, media, {"amf": ["hevc"]})
assert plan["engine"] == "cpu"
assert {item["codec_type"] for item in probe(folder / "amd-fallback-result.mp4")["streams"]} == {"video", "audio"}
with contextlib.redirect_stdout(io.StringIO()):
try: convert(source, folder / "amd-forced-result.mp4", {**options, "engine": "amf"}, media, {"amf": ["hevc"]})
except ValueError as error: assert "FFmpeg" in str(error)
else: raise AssertionError("Forced AMD mode must not silently use the CPU")
report = {"gpu": hardware["gpu"], "amfMeasured": hardware["amf"], "nvencMeasured": hardware["nvenc"], "autoFallback": "CPU passed", "forcedAMF": "rejected", "physicalAMDEncoding": "not tested: AMD GPU absent"}
(PROJECT / "test-artifacts/amd-fallback.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print("AMD negative-path checks passed: real AMF failure, automatic CPU fallback with audio, and forced-mode error.")
+53
View File
@@ -0,0 +1,53 @@
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";
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-components-test-'));
const context=await chromium.launchPersistentContext(profile,{
headless:true,viewport:{width:1000,height:900},
executablePath:'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
ignoreDefaultArgs:['--disable-extensions'],args:['--enable-unsafe-extension-debugging'],
});
try {
const cdp=await context.browser().newBrowserCDPSession();
const {id}=await cdp.send('Extensions.loadUnpacked',{path:path.join(root,'extension')});
const worker=context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');
await worker.evaluate(() => chrome.storage.local.set({ onboarding: { state: "complete", version: 1 } }));
const page=await context.newPage(); let installerDownloads=0;
page.on('download',()=>installerDownloads++);
await page.goto(`chrome-extension://${id}/downloader.html`);
await page.locator('#helper-panel[data-state="running"]').waitFor({timeout:90000});
await page.locator('#helper-panel > summary').click();
assert.equal(await page.locator('#helper-install').textContent(),'Переустановить');
const automatic=await page.locator('#auto-update').isChecked();
if(automatic) await page.locator('#auto-update').uncheck();
await page.locator('#helper-update').click();
const deadline=Date.now()+240000; let job;
while(Date.now()<deadline) {
const jobs=await worker.evaluate(async()=>Object.values((await chrome.storage.local.get('jobs')).jobs || {}));
job=jobs.find(item=>item.kind==='update');
if(job && job.status!=='running') break;
await new Promise(resolve=>setTimeout(resolve,200));
}
assert.ok(job); assert.equal(job.status,'complete',job.error);
assert.equal(installerDownloads,0,'Update components must use the helper, not download the embedded EXE');
await page.locator('#helper-panel[data-state="running"]').waitFor({timeout:90000});
await page.locator('#helper-update:not([disabled])').waitFor({timeout:30000});
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(),5);
assert.equal(await page.locator('#engine option[value="nvenc"]').isDisabled(),false);
assert.equal(await page.locator('#engine option[value="amf"]').isDisabled(),true);
await page.locator('#auto-update').setChecked(automatic);
await page.locator('.dependencies summary').click();
await page.screenshot({path:path.join(artifacts,'component-updater.png'),animations:'disabled'});
await fs.writeFile(path.join(artifacts,'browser-components.json'),JSON.stringify({updateButton:'passed',installerDownloads,job,automatic},null,2));
console.log('Browser component update passed: real native request, official version check, completed progress, ready tools, preserved automatic-update setting, no EXE download.');
} finally {
await context.close();
if(path.dirname(path.resolve(profile))===path.resolve(os.tmpdir()) && path.basename(profile).startsWith('ytdl-components-test-')) await fs.rm(profile,{recursive:true,force:true});
}
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
import { installedHelperRoot } from "./installed-helper.mjs";
// Uses a fresh browser profile and the registered native host. No browser accounts.
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const extension = path.join(root, "extension");
const artifacts = path.join(root, "test-artifacts");
const folder = path.join(artifacts, "media");
const videoId = process.env.YTDL_TEST_VIDEO_ID || "jNQXAC9IVRw";
assert.match(videoId, /^[\w-]{11}$/);
await fs.mkdir(folder, { recursive: true });
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "ytdl-browser-test-"));
const context = await chromium.launchPersistentContext(profile, {
headless: true, viewport: { width: 1280, height: 900 },
executablePath: process.env.YTDL_TEST_BROWSER || "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
ignoreDefaultArgs: ["--disable-extensions"], args: ["--enable-unsafe-extension-debugging"],
});
try {
const cdp = await context.browser().newBrowserCDPSession();
const installed = await cdp.send("Extensions.loadUnpacked", { path: extension });
assert.equal(installed.id, "ndnoeiaecgmjnjneekaeapeecmmjheli");
const worker = context.serviceWorkers()[0] || await context.waitForEvent("serviceworker", { timeout: 30000 });
assert.equal(new URL(worker.url()).hostname, "ndnoeiaecgmjnjneekaeapeecmmjheli");
await worker.evaluate(() => chrome.storage.local.set({ onboarding: { state: "complete", version: 1 } }));
// Only the browser page is a fixture; inspection, streams and NVENC are real.
await context.route("https://www.youtube.com/**", route => route.fulfill({
contentType: "text/html", body: '<!doctype html><html dark><head><meta charset="utf-8"></head><body style="margin:30px;background:#0f0f0f;color:white;font:14px Segoe UI"><div style="height:340px;background:#182837;border-radius:15px"></div><h1>Me at the zoo</h1><ytd-watch-metadata><div id="owner" style="display:flex;align-items:center"><div id="subscribe-button"><button style="border:0;border-radius:22px;padding:12px 18px">Подписаться</button></div></div></ytd-watch-metadata></body></html>',
}));
const page = await context.newPage();
const errors = [];
page.on("pageerror", error => errors.push(error.message));
await page.goto(`https://www.youtube.com/watch?v=${videoId}`);
await page.locator("#ytdl-subscribe-download .settings").click();
const ui = page.frameLocator("#ytdl-menu iframe");
try {
await Promise.race([
ui.locator("#download-form").waitFor({ state: "visible", timeout: 180000 }),
ui.locator('#alert:not([hidden])').waitFor({state:'visible',timeout:180000}).then(async()=>{ throw new Error(await ui.locator('#alert').innerText()); }),
]);
}
catch (error) {
console.log('Connection diagnostics:', await ui.locator('body').innerText());
console.log('Page errors:', errors);
await page.screenshot({path:path.join(artifacts,'browser-native-error.png'),animations:'disabled'});
throw error;
}
assert.match(await ui.locator("#hardware").textContent(), /RTX 5080/);
assert.equal(await ui.locator("#helper-status").textContent(), "Помощник работает");
assert.match(await ui.locator("#helper-message").textContent(), /Автономный комплект/);
assert.equal(await ui.locator('.dependency-row[data-status="installed"]').count(), 5);
await page.evaluate(() => document.documentElement.removeAttribute("dark"));
await ui.locator('html[data-theme="light"]').waitFor();
await page.evaluate(() => document.documentElement.setAttribute("dark", ""));
await ui.locator('html[data-theme="dark"]').waitFor();
const qualities = await ui.locator("#quality option").evaluateAll(nodes => nodes.map(n => ({ id: n.value, text: n.textContent })));
const separate = qualities.filter(q => q.id !== "best" && !q.text.includes("со звуком")).at(-1);
assert.ok(separate, "A real separate video stream must be available");
await ui.locator("#quality").selectOption(separate.id);
await ui.locator("#codec").selectOption("hevc");
await ui.locator(".advanced summary").click();
await ui.locator("#engine").selectOption("nvenc");
await ui.locator("#filename").fill("Browser-native-HEVC");
await ui.locator("#folder").fill(folder);
await ui.locator("#save-settings").click();
try { await page.locator("#ytdl-menu").waitFor({ state: "detached", timeout: 15000 }); }
catch (error) {
console.log("Menu alert:", await ui.locator("#alert").textContent());
console.log("Jobs:", await worker.evaluate(async () => (await chrome.storage.local.get("jobs")).jobs));
await page.screenshot({ path: path.join(artifacts, "browser-native-error.png"), animations: "disabled" });
throw error;
}
await page.locator("#ytdl-subscribe-download .download").click();
await page.locator("#ytdl-notifications .toast").waitFor();
console.log("Real extension connected; download started, on-page toast visible.");
await page.locator("#ytdl-notifications .toast.complete, #ytdl-notifications .toast.error").waitFor({ timeout: 240000 });
const saved = await worker.evaluate(async () => (await chrome.storage.local.get("jobs")).jobs);
const job = Object.values(saved).find(job => job.result?.path && job.url.includes(videoId));
assert.ok(job, JSON.stringify(saved));
assert.equal(job.status, "complete", job.error);
assert.equal(job.result.engine, "nvenc");
assert.equal(job.result.codec, "hevc");
const installedRoot = await installedHelperRoot();
const installedBundle = JSON.parse((await fs.readFile(path.join(installedRoot, "installation.json"), "utf8")).replace(/^\uFEFF/, ""));
const bundle = JSON.parse(await fs.readFile(path.join(installedBundle.current, "bundle.json"), "utf8"));
const probe = await promisify(execFile)(path.join(installedBundle.current, bundle.tools.ffprobe), ["-v", "error", "-show_streams", "-of", "json", job.result.path]);
const streams = JSON.parse(probe.stdout).streams;
assert.ok(streams.some(s => s.codec_type === "video" && s.codec_name === "hevc"));
assert.ok(streams.some(s => s.codec_type === "audio"));
assert.deepEqual(errors, []);
await page.screenshot({ path: path.join(artifacts, "browser-native-complete.png"), animations: "disabled" });
await fs.writeFile(path.join(artifacts, "browser-native.json"), JSON.stringify({ extensionId: new URL(worker.url()).hostname, job }, null, 2));
console.log("Browser/native checks passed: real MV3 extension, native messaging, separate audio, NVENC, saved file, live toast.");
} finally {
await context.close();
const resolved = path.resolve(profile);
if (path.dirname(resolved) === path.resolve(os.tmpdir()) && path.basename(resolved).startsWith("ytdl-browser-test-")) {
await fs.rm(resolved, { recursive: true, force: true });
}
}
+84
View File
@@ -0,0 +1,84 @@
"""Real staged publication, stale-update rejection and live PyPI wheels, no registry changes."""
import hashlib
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
PROJECT = Path(__file__).resolve().parent.parent
def inside(packages=False):
from host.bundle import ROOT, installation_root
from host.updates import update
from host.media import capabilities
application = installation_root()
marker = application / "installation.json"
before = json.loads(marker.read_text(encoding="utf-8-sig"))["current"]
if packages:
import io
import time
from host.main import NativeHost
host = NativeHost(io.BytesIO())
try:
response = host.start_update([{"id": "packages", "version": "latest-compatible"}])
deadline = time.monotonic() + 500
while host.jobs[response["jobId"]]["status"] == "running" and time.monotonic() < deadline: time.sleep(0.1)
job = host.jobs[response["jobId"]]
assert job["status"] == "complete", job
result = job["result"]
finally: host.close()
after = Path(json.loads(marker.read_text(encoding="utf-8-sig"))["current"])
versions = json.loads((after / "bundle.json").read_text(encoding="utf-8"))["pythonPackages"]
assert result["restartRequired"] and "yt-dlp" in {key.lower() for key in versions}
print(json.dumps({"livePyPI": "passed", "nativeWorkerUpdate": "passed", "packages": versions}))
return
archive = PROJECT / ".build-cache/node.zip"
data = archive.read_bytes()
candidate = {"id": "node", "version": "v24.21.0", "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip", "sha256": hashlib.sha256(data).hexdigest()}
class Response:
headers = {"Content-Length": str(len(data))}
def __enter__(self): return self
def __exit__(self, *args): return False
def iter_content(self, count):
for offset in range(0, len(data), count): yield data[offset:offset + count]
dll = ROOT / "runtime/python/python313.dll"
with dll.open("rb") as opened, patch("host.updates.get", return_value=Response()):
result = update(lambda *args: None, lambda _: None, [candidate])
after = json.loads(marker.read_text(encoding="utf-8-sig"))["current"]
assert after != before and result["restartRequired"] and dll.is_file() and opened.read(2) == b"MZ"
try: update(lambda *args: None, lambda _: None, [candidate])
except ValueError as error: assert "другой установкой" in str(error)
else: raise AssertionError("A stale helper must not replace the newer runtime")
try: update(lambda *args: None, lambda _: None, [{**candidate, "sha256": "0" * 64}])
except ValueError as error: assert "Контрольная сумма" in str(error)
else: raise AssertionError("Modified downloads must be rejected")
assert json.loads(marker.read_text(encoding="utf-8-sig"))["current"] == after
assert not list((application / "releases").glob(".ytdl-update-*")) == [], "The published release must remain"
print(json.dumps({"stagedActivation": "passed", "openOldDLL": "preserved", "staleUpdate": "rejected", "badHash": "rejected"}))
def outer():
artifacts = PROJECT / "test-artifacts"; artifacts.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="update-test-", dir=artifacts) as temporary:
root = Path(temporary) / "Helper"
process = subprocess.run([str(PROJECT / "dist/YouTubeDL-Helper-Setup.exe"), "--silent", "--no-register", "--target-root", str(root)], capture_output=True, text=True, encoding="utf-8", timeout=180)
assert process.returncode == 0, process.stdout + process.stderr
records = []
for action in ("--inside", "--inside-packages"):
marker = json.loads((root / "installation.json").read_text(encoding="utf-8-sig"))
python = Path(marker["current"]) / "runtime/python/python.exe"
result = subprocess.run([str(python), "-I", "-X", "utf8", "-B", str(Path(__file__).resolve()), action], capture_output=True, text=True, encoding="utf-8", timeout=600)
assert result.returncode == 0, result.stdout[-1000:] + result.stderr[-4000:]
records.append(json.loads(result.stdout.splitlines()[-1]))
(artifacts / "component-update.json").write_text(json.dumps(records, indent=2), encoding="utf-8")
print("Component updates passed: live PyPI downloads, verified stage activation, retained old DLL, bad hash rejection and stale-update rejection.")
if __name__ == "__main__":
if "--inside" in sys.argv: inside()
elif "--inside-packages" in sys.argv: inside(True)
else: outer()
+73
View File
@@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import crypto from "node:crypto";
import { canonicalUrl, bytes, duration, progressText, preferenceOptions, stabilizeDownloadSpeed, checkHelperCompatibility } from "../extension/shared.js";
test("URLs accept watch and Shorts but reject arbitrary origins and credentials", () => {
assert.equal(canonicalUrl("https://www.youtube.com/shorts/jNQXAC9IVRw"), "https://www.youtube.com/watch?v=jNQXAC9IVRw");
assert.equal(canonicalUrl("https://youtu.be/jNQXAC9IVRw?list=123"), "https://www.youtube.com/watch?v=jNQXAC9IVRw");
for (const url of ["https://youtube.com.evil/watch?v=jNQXAC9IVRw", "http://youtube.com/watch?v=jNQXAC9IVRw", "https://user@youtube.com/watch?v=jNQXAC9IVRw"]) assert.equal(canonicalUrl(url), null);
});
test("progress labels distinguish download tracks and GPU conversion", () => {
assert.equal(progressText({ stage: "downloading", track: "audio" }), "Скачивание · аудио");
assert.equal(progressText({ stage: "encoding", engine: "nvenc" }), "Перекодирование · NVIDIA NVENC");
assert.equal(progressText({ stage: "encoding", engine: "amf" }), "Перекодирование · AMD AMF");
assert.equal(progressText({ stage: "encoding", engine: "cpu" }), "Перекодирование · CPU");
assert.equal(progressText({ stage: "merging", engine: "copy" }), "Объединение дорожек · без перекодирования");
assert.equal(progressText({ stage: "remuxing", engine: "copy" }), "Подготовка файла · без перекодирования");
assert.equal(progressText({ stage: "finalizing", engine: "copy", message: "Финализация файла" }), "Финализация файла");
assert.equal(progressText({ stage: "audio", engine: "cpu" }), "Обработка аудио · CPU");
assert.equal(duration(3661), "1:01:01");
assert.equal(duration(61), "1:01");
assert.equal(bytes(0), "");
assert.match(bytes(1048576), /1 МБ/);
});
test("saved quality adapts to another video's formats and never upscales", () => {
const metadata = { videos: [{ id: "new-4k", height: 2160, fps: 60, codec: "av01" }, { id: "new-720", height: 720, fps: 30, codec: "avc1" }],
audios: [{ id: "new-audio", language: "en" }] };
const preference = { folder: "D:\\Videos", videoId: "old-format", audioId: "old-audio", quality: { height: 1080, fps: 30, codec: "H.264" }, audioLanguage: "en", codec: "hevc", engine: "nvenc", videoContainer: "mkv" };
const result = preferenceOptions(metadata, preference);
assert.equal(result.videoId, "new-720"); assert.equal(result.audioId, "new-audio");
assert.equal(result.codec, "hevc"); assert.equal(result.container, "mkv"); assert.equal(result.folder, "D:\\Videos");
assert.equal(preferenceOptions(metadata, { ...preference, videoId: "best" }).videoId, "best");
assert.equal(preferenceOptions(metadata, { ...preference, mode: "audio", audioContainer: "mp3" }).container, "mp3");
});
test("fixed extension ID matches the native host allowed origin", () => {
const manifest = JSON.parse(fs.readFileSync(new URL("../extension/manifest.json", import.meta.url)));
const digest = crypto.createHash("sha256").update(Buffer.from(manifest.key, "base64")).digest().subarray(0, 16).toString("hex");
const id = [...digest].map(character => "abcdefghijklmnop"[parseInt(character, 16)]).join("");
assert.equal(id, "ndnoeiaecgmjnjneekaeapeecmmjheli");
assert.ok(manifest.permissions.includes("nativeMessaging"));
assert.ok(!manifest.host_permissions.includes("<all_urls>"));
});
test("brief missing speed samples do not flicker, expire and reset between tracks", () => {
const sample = { id: "speed", status: "running", stage: "downloading", track: "video", speed: 4.2 * 1048576 };
let previous = stabilizeDownloadSpeed(sample, null, 1000);
for (const now of [1200, 1600, 2000, 2400]) {
previous = stabilizeDownloadSpeed({ ...sample, speed: null }, previous, now);
assert.equal(previous.speed, sample.speed);
assert.equal(previous.speedUpdatedAt, 1000, 'Missing samples must not extend the lifetime of the last measurement');
}
assert.equal(stabilizeDownloadSpeed({ ...sample, speed: null }, previous, 2500).speed, null);
assert.equal(stabilizeDownloadSpeed({ ...sample, speed: 0 }, previous, 1200).speed, 0);
assert.equal(stabilizeDownloadSpeed({ ...sample, track: "audio", speed: null }, previous, 1200).speed, null);
assert.equal(stabilizeDownloadSpeed({ ...sample, stage: "merging", speed: "8x" }, previous, 1200).speedUpdatedAt, null);
});
test("helper compatibility detects outdated, newer and unidentifiable versions in both directions", () => {
assert.equal(checkHelperCompatibility({ version: "1.3.2" }, "1.4.5").compatible, true);
assert.equal(checkHelperCompatibility({ bundle: { version: "1.3.2" } }, "1.4.5").compatible, true);
assert.equal(checkHelperCompatibility({ version: "1.3.2.0" }, "1.4.5").compatible, true);
for (const version of ["1.3.0", "1.2.10", undefined, "unknown"]) {
const result = checkHelperCompatibility({ version }, "1.4.5");
assert.equal(result.compatible, false);
assert.equal(result.update, "helper");
}
for (const version of ["1.3.3", "1.3.20", "2.0.0"]) {
const result = checkHelperCompatibility({ version }, "1.4.5");
assert.equal(result.compatible, false);
assert.equal(result.update, "extension");
}
assert.equal(checkHelperCompatibility({ version: "1.3.2" }, "1.4.2").update, "extension");
assert.equal(checkHelperCompatibility({ version: "1.3.2", minimumExtensionVersion: "1.4.6" }, "1.4.5").update, "extension");
});
+95
View File
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
import { installedHelperRoot } from "./installed-helper.mjs";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
const installedRoot = await installedHelperRoot();
await fs.mkdir(artifacts, { recursive: true });
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "ytdl-lifecycle-test-"));
const context = await chromium.launchPersistentContext(profile, {
headless: true, acceptDownloads: true,
executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
ignoreDefaultArgs: ["--disable-extensions"], args: ["--enable-unsafe-extension-debugging"],
});
try {
const cdp = await context.browser().newBrowserCDPSession();
const { id } = 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(`chrome-extension://${id}/downloader.html`);
await page.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await page.locator('#setup[open]').waitFor();
await page.locator('#setup-next').click();
await page.locator('#setup-next:not([disabled])').waitFor();
assert.match(await page.locator('#setup-helper-status').textContent(), /компоненты готовы/);
assert.match(await page.locator('#setup-gpu').textContent(), /RTX 5080/);
await page.locator('#setup-next').click();
assert.ok(await page.locator('#setup-folder').inputValue());
await page.locator('#setup-quality').selectOption('1080');
await page.locator('#setup-next').click();
await page.locator('[data-setup-step="3"]').waitFor({state:'visible'});
await page.screenshot({path:path.join(artifacts,'onboarding-native.png'),animations:'disabled'});
await page.locator('#setup-next').click();
await page.locator('#setup').waitFor({state:'hidden'});
const firstRun = await worker.evaluate(() => chrome.storage.local.get(['preferences','onboarding']));
assert.equal(firstRun.onboarding.state,'complete'); assert.equal(firstRun.preferences.quality.height,1080);
await page.reload();
await page.locator('#helper-panel[data-state="running"]').waitFor({timeout:90000});
assert.equal(await page.locator('#setup').evaluate(dialog => dialog.open),false);
await page.locator("#helper-panel > summary").click();
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(), 5);
await context.route("https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest", route => route.fulfill({ contentType: "text/html", body: "<h1>релизов проекта test destination</h1>" }));
const releaseTab = page.waitForEvent("popup");
await page.locator("#helper-install").click();
const popup = await releaseTab;
await popup.waitForURL("https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest");
assert.equal(await page.locator("#helper-install").getAttribute("download"), null);
await popup.close();
const installerPath = path.join(artifacts, "YouTubeDL-Helper-Test.exe");
await fs.copyFile(path.join(root, "dist/YouTubeDL-Helper-Setup.exe"), installerPath);
const downloaded = await fs.readFile(installerPath);
assert.equal(downloaded.subarray(0, 2).toString(), "MZ");
assert.equal(createHash("sha256").update(downloaded).digest("hex"), createHash("sha256").update(await fs.readFile(path.join(root,"dist/YouTubeDL-Helper-Setup.exe"))).digest("hex"));
console.log("Extension opens the external release page; installer comes from the separate release asset.");
await page.locator("#helper-remove").click();
await page.locator('#helper-panel[data-state="missing"]').waitFor();
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(), 0);
await page.locator("#helper-start").click();
await page.locator('#helper-panel[data-state="missing"]').waitFor();
const deadline = Date.now() + 40000;
while (Date.now() < deadline) {
try { await fs.access(path.join(installedRoot,"installation.json")); }
catch { break; }
await new Promise(resolve => setTimeout(resolve, 100));
}
await assert.rejects(fs.access(path.join(installedRoot,"installation.json")));
console.log("Uninstall verified; missing helper cannot report Running.");
const installed = await promisify(execFile)("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(root, "tools/run-installer-test.ps1"), "-InstallerPath", installerPath], { timeout: 600000 });
console.log(installed.stdout.trim());
await page.locator("#helper-start").click();
await page.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(), 5);
assert.equal(await page.locator('.dependency-row button').count(), 0);
assert.match(await page.locator('#helper-message').textContent(), /Автономный комплект/);
assert.match(await page.locator('[data-dependency="python"] .dependency-version').textContent(), /3\.13\./);
const restoredRoot = await installedHelperRoot();
const restoredMarker = JSON.parse((await fs.readFile(path.join(restoredRoot,'installation.json'),'utf8')).replace(/^\uFEFF/,''));
assert.equal(restoredMarker.version,'1.3.0');
await fs.access(path.join(restoredRoot,'YouTubeDL-Manager.exe'));
assert.equal(path.dirname(path.dirname(path.resolve(restoredMarker.current))),path.resolve(restoredRoot),'Manager, marker and registered runtime must share the same physical installation');
if (!await page.locator('#helper-panel').evaluate(panel => panel.open)) await page.locator('#helper-panel > summary').click();
await page.locator('.dependencies summary').click();
await page.screenshot({ path: path.join(artifacts, "helper-components.png"), animations: "disabled" });
await fs.writeFile(path.join(artifacts, "helper-lifecycle.json"), JSON.stringify({ installer: "YouTubeDL-Helper-Setup.exe", externalReleaseLink: "passed", installerSHA256: createHash("sha256").update(downloaded).digest("hex"), firstRun: "passed", firstRunPersistence: "passed", uninstall: "passed", manualStart: "passed", autonomousBundle: "passed" }, null, 2));
console.log("Helper lifecycle passed: external release link, scoped uninstall, missing state, offline installation, manual start and bundled components.");
} finally {
await context.close();
if (path.dirname(path.resolve(profile)) === path.resolve(os.tmpdir()) && path.basename(profile).startsWith("ytdl-lifecycle-test-")) await fs.rm(profile, { recursive: true, force: true });
}
+144
View File
@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { randomBytes } from "node:crypto";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
// Real Edge, real ports, and the bundled installer/helper. Only their host name
// is isolated so testing removal never touches the user's installed helper.
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
const build = path.join(root, ".build");
await fs.mkdir(build, { recursive: true }); await fs.mkdir(artifacts, { recursive: true });
const work = await fs.mkdtemp(path.join(build, "reconnect-"));
const extension = path.join(work, "extension"), helper = path.join(work, "helper");
const name = `com.youtubedl.test_${randomBytes(8).toString("hex")}`;
const run = promisify(execFile);
let manifestPath, context;
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "ytdl-reconnect-test-"));
const userRegistration = async () => (await run("powershell.exe", ["-NoProfile", "-Command",
"(Get-Item -LiteralPath 'Registry::HKEY_CURRENT_USER\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.youtubedl.native' -ErrorAction SilentlyContinue).GetValue('')"])).stdout.trim();
const beforeRegistration = await userRegistration();
async function register(remove = false) {
await run("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(root, "tests/test-native-registration.ps1"),
"-Name", name, "-ManifestPath", manifestPath, ...(remove ? ["-Remove"] : [])]);
}
async function install() {
const result = await run(path.join(root, "dist/YouTubeDL-Helper-Setup.exe"), ["--silent", "--no-register", "--target-root", helper], { timeout: 180000, windowsHide: true });
assert.match(result.stdout, /100%|Готово/);
const marker = JSON.parse((await fs.readFile(path.join(helper, "installation.json"), "utf8")).replace(/^\uFEFF/, ""));
assert.equal(path.dirname(path.dirname(path.resolve(marker.current))), path.resolve(helper));
const commonPath = path.join(marker.current, "host/common.py");
await fs.writeFile(commonPath, (await fs.readFile(commonPath, "utf8")).replace('HOST_NAME = "com.youtubedl.native"', `HOST_NAME = "${name}"`));
manifestPath = path.join(marker.current, ".runtime/native-host.json");
const manifest = JSON.parse((await fs.readFile(manifestPath, "utf8")).replace(/^\uFEFF/, ""));
manifest.name = name; await fs.writeFile(manifestPath, JSON.stringify(manifest));
await register();
}
try {
await fs.cp(path.join(root, "extension"), extension, { recursive: true });
const sharedPath = path.join(extension, "shared.js");
await fs.writeFile(sharedPath, (await fs.readFile(sharedPath, "utf8")).replace('"com.youtubedl.native"', `"${name}"`));
context = await chromium.launchPersistentContext(profile, {
headless: true, viewport: { width: 520, height: 820 },
executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
ignoreDefaultArgs: ["--disable-extensions"], args: ["--enable-unsafe-extension-debugging"],
});
const cdp = await context.browser().newBrowserCDPSession();
const { id } = await cdp.send("Extensions.loadUnpacked", { path: extension });
const worker = context.serviceWorkers()[0] || await context.waitForEvent("serviceworker");
// Capture real ports to trigger both sides of a disconnect deterministically.
await worker.evaluate(() => {
globalThis.__testUiPorts = []; globalThis.__testNativePorts = [];
chrome.runtime.onConnect.addListener(port => { if (port.name === "ytdl-ui") globalThis.__testUiPorts.push(port); });
const original = chrome.runtime.connectNative.bind(chrome.runtime);
chrome.runtime.connectNative = (...args) => { const port = original(...args); globalThis.__testNativePorts.push(port); return port; };
});
const page = await context.newPage(), errors = [];
page.on("pageerror", error => errors.push(error.message));
await page.addInitScript(() => {
window.__testUiPorts = [];
const original = chrome.runtime.connect.bind(chrome.runtime);
chrome.runtime.connect = (...args) => { const port = original(...args); window.__testUiPorts.push(port); return port; };
window.__pageToken = crypto.randomUUID();
});
await page.goto(`chrome-extension://${id}/downloader.html?theme=dark`);
const token = await page.evaluate(() => window.__pageToken);
await page.locator('#setup[open]').waitFor(); await page.locator('#setup-next').click();
await page.locator('#setup-helper[data-state="missing"]').waitFor();
await worker.evaluate(() => globalThis.__testUiPorts.at(-1).disconnect());
await page.locator('#setup-helper[data-state="offline"]').waitFor();
console.log("Real UI port disconnected while the helper was missing. Installing a private offline bundle.");
await install();
await page.locator('#setup-check').click();
await page.locator('#setup-helper[data-state="running"]').waitFor({ timeout: 90000 });
assert.match(await page.locator('#setup-helper-message').textContent(), /5\/5/);
// Chrome does not signal onDisconnect to the end that called disconnect().
// This reproduces the exact synchronous 'disconnected port object' race.
await page.evaluate(() => window.__testUiPorts.at(-1).disconnect());
await page.locator('#setup-check').click();
await page.locator('#setup-check:not([disabled])').waitFor();
assert.equal(await page.locator('#setup-helper').getAttribute('data-state'), 'running');
assert.equal(await page.evaluate(() => window.__pageToken), token);
await page.screenshot({ path: path.join(artifacts, "helper-reconnect-setup.png") });
console.log("Wizard Check passed after installation and both real port-disconnect cases, without page reload.");
await page.locator('#setup-skip').click();
await page.locator('#helper-panel').evaluate(panel => { panel.open = true; });
const other = await context.newPage(); other.on('pageerror', error => errors.push(error.message));
await other.goto(`chrome-extension://${id}/downloader.html?theme=dark`);
await other.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await worker.evaluate(() => globalThis.__testUiPorts.forEach(port => { try { port.disconnect(); } catch {} }));
await page.locator('#helper-panel[data-state="offline"]').waitFor();
await page.locator('#helper-start').click();
await page.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await other.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await page.locator('#helper-remove').click();
await page.locator('#helper-panel[data-state="missing"]').waitFor();
await other.locator('#helper-panel[data-state="missing"]').waitFor();
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(), 0);
await page.screenshot({ path: path.join(artifacts, "helper-reconnect-removed.png") });
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
try { await fs.access(path.join(helper, 'installation.json')); }
catch { break; }
await new Promise(resolve => setTimeout(resolve, 250));
}
await assert.rejects(fs.access(path.join(helper, 'installation.json')));
console.log("Start and actual helper removal passed; both open menus report Missing immediately.");
await install();
await page.locator('#helper-start').click();
await page.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await other.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
// External registration removal and native-port loss must be discovered by polling.
await register(true);
await worker.evaluate(() => globalThis.__testNativePorts.at(-1).disconnect());
await page.locator('#helper-panel[data-state="missing"]').waitFor({ timeout: 25000 });
await other.locator('#helper-panel[data-state="missing"]').waitFor({ timeout: 25000 });
await register();
await page.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await other.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
assert.equal(await page.evaluate(() => window.__pageToken), token);
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(), 5);
assert.equal(await page.locator('#alert').isVisible(), false);
assert.deepEqual(errors, []);
assert.equal(await userRegistration(), beforeRegistration);
await page.screenshot({ path: path.join(artifacts, "helper-reconnect-ready.png") });
await fs.writeFile(path.join(artifacts, "helper-reconnect-result.json"), JSON.stringify({
version: JSON.parse(await fs.readFile(path.join(root,'extension/manifest.json'))).version,
actualEdge: true, actualInstaller: true, actualNativePorts: true, checkAfterInstall: true,
startAfterDisconnect: true, removalUpdatesAllMenus: true, reinstall: true,
pollingDetectsExternalChanges: true, noPageReload: true, userRegistrationUnchanged: true,
}, null, 2));
console.log("Real helper reconnect passed: Check, Start, removal, reinstall, external changes, two open menus, no page reload.");
} finally {
if (context) await context.close();
if (manifestPath) await register(true);
assert.equal(await userRegistration(), beforeRegistration);
if (path.dirname(path.resolve(profile)) === path.resolve(os.tmpdir()) && path.basename(profile).startsWith("ytdl-reconnect-test-")) await fs.rm(profile, { recursive: true, force: true, maxRetries: 20, retryDelay: 200 });
if (path.dirname(path.resolve(work)) === path.resolve(build) && path.basename(work).startsWith("reconnect-")) await fs.rm(work, { recursive: true, force: true, maxRetries: 20, retryDelay: 200 });
}
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
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-history-test-"));
const context = await chromium.launchPersistentContext(profile, { headless: true, viewport: { width: 1180, height: 900 },
executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", reducedMotion: "reduce",
ignoreDefaultArgs: ["--disable-extensions"], args: ["--enable-unsafe-extension-debugging"] });
const errors = [];
context.on("page", page => page.on("pageerror", error => errors.push(error.message)));
const videoUrl = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
const records = {
saved: { id: "saved", title: "Subnautica · Путешествие в глубину", kind: "download", status: "complete", stage: "complete", createdAt: 1791620000,
url: videoUrl, result: { filename: "Путешествие в глубину.mp4", path: "D:\\Videos\\Путешествие в глубину.mp4", size: 512 * 1048576 } },
cancelled: { id: "cancelled", title: "Отменённое скачивание", kind: "download", status: "cancelled", createdAt: 1791610000 },
error: { id: "error", title: "Не удалось скачать видео", kind: "download", status: "error", error: "YouTube запросил подтверждение входа. Подключите вход из текущего профиля браузера в настройках.", url: videoUrl, createdAt: 1791630000 },
updateError: { id: "update-error", title: "Обновление компонентов", kind: "update", status: "error", error: "Не удалось получить обновление FFmpeg. Проверьте соединение и повторите попытку.", createdAt: 1791600000 },
unsafe: { id: "unsafe", title: '<img src=x onerror="alert(1)">', status: "error", url: "https://evil.test/", error: 'Ошибка <script>alert(1)</script>\n' + "Длинное сообщение. ".repeat(30), createdAt: 1791590000 },
updateComplete: { id: "update-complete", title: "Обновление компонентов", kind: "update", status: "complete", createdAt: 1791580000 },
};
try {
const cdp = await context.browser().newBrowserCDPSession();
const { id } = await cdp.send("Extensions.loadUnpacked", { path: path.join(root, "extension") });
let worker = context.serviceWorkers()[0] || await context.waitForEvent("serviceworker");
await worker.evaluate(async jobs => { await chrome.storage.local.set({ jobs, theme: "dark" }); }, records);
const reloaded = context.waitForEvent("serviceworker", { predicate: next => next !== worker, timeout: 20000 });
await worker.evaluate(() => { setTimeout(() => chrome.runtime.reload(), 50); });
worker = await reloaded;
const page = await context.newPage();
await page.goto(`chrome-extension://${id}/history.html`);
await page.locator('#downloads-list [data-job-id="saved"]').waitFor();
await page.waitForTimeout(100);
assert.equal(await page.locator('#downloads-list .history-entry').count(), 2);
assert.equal(await page.locator('#downloads-count').textContent(), "2");
assert.equal(await page.locator('#errors-count').textContent(), "3");
assert.match(await page.locator('[data-job-id="saved"] .entry-meta').textContent(), /512 МБ/);
assert.match(await page.locator('[data-job-id="saved"] .entry-path').textContent(), /D:\\Videos/);
assert.equal(await page.locator('[data-job-id="saved"] a').getAttribute("href"), videoUrl);
assert.equal(await page.locator('#errors-panel').isVisible(), false);
assert.equal(await page.locator('#history-connection').isVisible(), false, "The real history port must be accepted by the service worker");
await page.screenshot({ path: path.join(artifacts, "history-downloads-dark.png") });
await page.locator('#errors-tab').click();
assert.equal(await page.locator('#downloads-panel').isVisible(), false);
assert.equal(await page.locator('#errors-list .history-entry').count(), 3);
assert.equal(await page.locator('[data-job-id="unsafe"] img, [data-job-id="unsafe"] script, [data-job-id="unsafe"] a').count(), 0, "Titles/errors are text, and unsafe URLs have no link");
await page.screenshot({ path: path.join(artifacts, "history-errors-dark.png") });
await page.locator('#errors-tab').press('ArrowLeft');
assert.equal(await page.locator('#downloads-tab').getAttribute('aria-selected'), "true");
assert.equal(await page.locator('#downloads-tab').evaluate(node => node === document.activeElement), true);
// An actual extension UI port must create an actual browser tab.
const [opened] = await Promise.all([context.waitForEvent("page"), page.evaluate(async () => {
const { createUiConnection } = await import(chrome.runtime.getURL("ui-connection.js"));
const { call } = createUiConnection({}); await call("open_history", { theme: "dark" });
})]);
await opened.waitForURL(`chrome-extension://${id}/history.html?theme=dark`);
await opened.locator('#downloads-list .history-entry').first().waitFor();
await opened.close();
await page.setViewportSize({ width: 360, height: 800 });
await page.locator('#theme').click(); // dark -> auto
await page.locator('#theme').click(); // auto -> light
assert.equal(await page.locator('html').getAttribute('data-theme'), "light");
await page.locator('#errors-tab').click();
assert.equal(await page.locator('html').evaluate(node => node.scrollWidth > node.clientWidth), false, "History and long errors must fit a narrow viewport");
await page.screenshot({ path: path.join(artifacts, "history-errors-light-360.png"), fullPage: true });
await page.reload();
assert.equal(await page.locator('html').getAttribute('data-theme'), "light");
await page.locator('#downloads-list [data-job-id="saved"]').waitFor();
assert.equal(await page.locator('#downloads-count').textContent(), "2", "Stored records survive page reload");
// Browser fixture covers arriving terminal events without starting a real download.
await context.route("https://ui.ytdl.test/**", async route => {
const name = new URL(route.request().url()).pathname.slice(1);
if (!["history.html", "history.js", "history.css", "styles.css", "theme-init.js", "shared.js", "ui-connection.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 fixture = await context.newPage();
await fixture.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
await fixture.goto("https://ui.ytdl.test/history.html");
await fixture.locator('#downloads-empty').waitFor();
await fixture.evaluate(records => { window.__emitJob(records.saved); }, records);
await fixture.locator('#downloads-list [data-job-id="saved"]').waitFor();
await fixture.locator('#errors-tab').click();
await fixture.evaluate(records => { window.__emitJob(records.error); }, records);
await fixture.locator('#errors-list [data-job-id="error"]').waitFor();
assert.equal(await fixture.locator('#errors-tab').getAttribute('aria-selected'), "true", "New records must preserve the chosen tab");
await fixture.evaluate(() => window.__emitJob({ id: "active", kind: "download", status: "running", stage: "downloading", createdAt: 1791650000 }));
assert.equal(await fixture.locator('#downloads-count').textContent(), "1");
assert.equal(await fixture.locator('#errors-count').textContent(), "1");
await fixture.evaluate(records => {
window.__uiDisconnect();
window.__fixtureStorage.jobs[records.updateError.id] = records.updateError;
}, records);
await fixture.waitForFunction(() => document.getElementById('errors-count').textContent === "2");
assert.equal(await fixture.locator('#history-connection').isVisible(), false, "History reconnects and re-reads local records without reload");
assert.deepEqual(errors, []);
console.log("History passed in actual Edge extension: persistent records, separate tabs, real new-tab action, safe text, themes, keyboard, 360px layout and live completion/error updates.");
} finally {
await context.close();
if (path.dirname(path.resolve(profile)) === path.resolve(os.tmpdir()) && path.basename(profile).startsWith("ytdl-history-test-")) await fs.rm(profile, { recursive: true, force: true });
}
+59
View File
@@ -0,0 +1,59 @@
import test from "node:test";
import assert from "node:assert/strict";
test("history connects without a helper, opens a tab, and retains failures before download starts", async () => {
const url = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
const storage = { jobs: {
saved: { id: "saved", title: "Сохранённое видео", kind: "download", status: "complete", result: { path: "D:/Videos/saved.mp4" }, createdAt: 1 },
interrupted: { id: "interrupted", status: "running", createdAt: 2 },
} };
const listeners = {}, tabs = [], nativeRequests = [];
const noEvent = { addListener() {} };
const runtime = { id: "history-test", getManifest: () => ({ version: "1.4.5" }), getURL: file => `chrome-extension://history-test/${file}`,
onConnect: { addListener: listener => { listeners.connect = listener; } }, onMessage: noEvent,
connectNative() {
let receive;
return { onMessage: { addListener: listener => { receive = listener; } }, onDisconnect: noEvent, disconnect() {},
postMessage(message) {
nativeRequests.push(message);
queueMicrotask(() => receive({ id: message.id, ok: message.action !== "inspect", result: message.action === "hello" ? { version: "1.3.2" } : { alive: true }, error: "Видео недоступно" }));
} };
} };
globalThis.chrome = { runtime, storage: { local: { get: async () => structuredClone(storage), set: async data => Object.assign(storage, structuredClone(data)) } },
tabs: { create: async tab => { tabs.push(tab); }, sendMessage: async () => {} },
notifications: { clear: async () => {}, create: async () => {}, update: async () => true, onButtonClicked: noEvent },
permissions: { contains: async () => false },
action: { onClicked: noEvent, setBadgeText() {}, setBadgeBackgroundColor() {} },
};
await import("../extension/background.js?history-test");
const attach = file => {
let send;
const events = [], pending = new Map();
listeners.connect({ name: "ytdl-ui", sender: { id: runtime.id, url: runtime.getURL(file) }, onDisconnect: noEvent,
onMessage: { addListener: listener => { send = listener; } },
postMessage(message) {
if (message.id) { pending.get(message.id)?.(message); pending.delete(message.id); }
else events.push(message);
} });
return { connected: () => Boolean(send), events, call: (action, payload = {}) => new Promise(resolve => {
const id = crypto.randomUUID(); pending.set(id, resolve); send({ id, action, payload });
}) };
};
const history = attach("history.html?theme=dark"), menu = attach("downloader.html?embedded=1");
assert.ok(history.connected());
assert.ok(!attach("history.html.evil").connected());
assert.ok(!attach("content.js").connected());
const state = (await history.call("state")).result;
assert.equal(state.jobs.length, 2);
assert.equal(state.jobs.find(job => job.id === "interrupted").status, "error");
assert.equal(nativeRequests.length, 0, "Viewing history must not launch or query the helper");
assert.deepEqual((await menu.call("open_history", { theme: "dark" })).result, { opened: true });
assert.deepEqual(tabs, [{ url: runtime.getURL("history.html?theme=dark") }]);
const inspection = await menu.call("inspect", { url });
assert.equal(inspection.ok, false);
assert.equal(inspection.error, "Видео недоступно");
const failure = Object.values(storage.jobs).find(job => job.kind === "inspection");
assert.equal(failure.error, "Видео недоступно"); assert.equal(failure.url, url);
assert.ok(history.events.some(message => message.job?.id === failure.id), "Already open history receives failures immediately");
assert.ok(!JSON.stringify(storage).includes("browserCookies"));
});
+23
View File
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { HOST_NAME } from "../extension/shared.js";
export async function installedHelperRoot() {
if (process.env.YTDL_INSTALLED_ROOT) return process.env.YTDL_INSTALLED_ROOT;
// A packaged parent process can redirect AppData. Use the registration that
// Edge actually opens, rather than a potentially stale LOCALAPPDATA directory.
assert.match(HOST_NAME, /^[a-z.]+$/);
const command = `$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); (Get-Item -LiteralPath 'Registry::HKEY_CURRENT_USER\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${HOST_NAME}').GetValue('')`;
const registration = (await promisify(execFile)("powershell.exe", ["-NoProfile", "-Command", command])).stdout.trim();
const native = JSON.parse((await fs.readFile(registration, "utf8")).replace(/^\uFEFF/, ""));
assert.equal(native.name, HOST_NAME);
const release = path.dirname(path.dirname(native.path));
const bundle = JSON.parse(await fs.readFile(path.join(release, "bundle.json"), "utf8"));
assert.equal(bundle.product, "YouTubeDL.Helper");
assert.equal(bundle.mode, "autonomous");
assert.equal(path.basename(path.dirname(release)), "releases");
return path.dirname(path.dirname(release));
}
+166
View File
@@ -0,0 +1,166 @@
"""Real FFmpeg + native messaging checks. Add --online for a small public YouTube download."""
from __future__ import annotations
import argparse
import contextlib
import io
import json
import queue
import subprocess
import sys
import threading
import time
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from host.common import EXTENSION_ID
from host.media import NO_WINDOW, capabilities, config, probe, tool
from host.protocol import read_message, write_message
from host.worker import convert
class NativeSession:
def __init__(self):
launcher = ROOT / ".runtime" / "launch-host.cmd"
self.process = subprocess.Popen([str(launcher), f"chrome-extension://{EXTENSION_ID}/"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=NO_WINDOW)
self.messages = queue.Queue()
self.jobs = {}
self.errors = []
def read():
try:
while (message := read_message(self.process.stdout)) is not None:
self.messages.put(message)
except Exception as error:
self.messages.put({"protocolError": str(error)})
threading.Thread(target=read, daemon=True).start()
threading.Thread(target=lambda: self.errors.append(self.process.stderr.read().decode("utf-8", "replace")), daemon=True).start()
def send(self, action, payload=None):
token = str(uuid.uuid4())
write_message(self.process.stdin, {"id": token, "action": action, "payload": payload or {}})
return token
def next(self, timeout=150):
message = self.messages.get(timeout=timeout)
if "protocolError" in message:
raise AssertionError(message)
if message.get("event") == "job":
self.jobs[message["job"]["id"]] = message["job"]
return message
def call(self, action, payload=None):
token = self.send(action, payload)
while True:
message = self.next()
if message.get("id") == token:
if not message["ok"]:
raise AssertionError(message["error"])
return message["result"]
def download(self, payload):
result = self.call("download", payload)
job_id = result["jobId"]
stage = None
deadline = time.monotonic() + 240
while time.monotonic() < deadline:
job = self.jobs.get(job_id, {})
if job.get("status") == "complete": return job["result"]
if job.get("status") in {"error", "cancelled"}: raise AssertionError(job)
self.next()
job = self.jobs.get(job_id, {})
if job.get("stage") != stage:
stage = job.get("stage"); print(f"Native job: {stage}", flush=True)
raise TimeoutError("Native job timeout")
def close(self):
self.process.stdin.close()
self.process.wait(timeout=30)
if self.process.returncode:
raise AssertionError("Native host exit: " + "".join(self.errors))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--online", action="store_true")
args = parser.parse_args()
artifacts = ROOT / "test-artifacts"
artifacts.mkdir(exist_ok=True)
media_dir = artifacts / "media"
media_dir.mkdir(exist_ok=True)
hardware = capabilities()
synthetic = media_dir / "input-vp9-opus.webm"
subprocess.run([tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=s=640x360:r=30",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "0.6", "-c:v", "libvpx-vp9",
"-cpu-used", "8", "-c:a", "libopus", str(synthetic)], check=True, creationflags=NO_WINDOW)
summary = {"gpu": hardware["gpu"], "nvenc": hardware["nvenc"], "formats": [], "native": {}}
defaults = {"mode": "video", "container": "mp4", "codec": "h264", "engine": "nvenc", "preset": "balanced", "bitrate": 256}
source_probe = probe(synthetic)
for codec in ("h264", "hevc", "av1"):
target = media_dir / f"gpu-{codec}.mp4"
with contextlib.redirect_stdout(io.StringIO()):
plan = convert(synthetic, target, {**defaults, "codec": codec}, source_probe, hardware["nvenc"])
streams = probe(target)["streams"]
assert plan["engine"] == "nvenc"
assert {s["codec_type"] for s in streams} >= {"video", "audio"}
assert next(s for s in streams if s["codec_type"] == "video")["codec_name"] == codec
summary["formats"].append({"format": codec, "engine": plan["engine"], "size": target.stat().st_size})
for container in ("m4a", "mp3", "opus", "flac", "wav"):
target = media_dir / f"audio.{container}"
with contextlib.redirect_stdout(io.StringIO()):
convert(synthetic, target, {**defaults, "mode": "audio", "container": container}, source_probe, hardware["nvenc"])
assert {s["codec_type"] for s in probe(target)["streams"]} == {"audio"}
summary["formats"].append({"format": container, "size": target.stat().st_size})
target = media_dir / "video-only.mkv"
with contextlib.redirect_stdout(io.StringIO()):
plan = convert(synthetic, target, {**defaults, "mode": "video_only", "container": "mkv", "codec": "auto"}, source_probe, hardware["nvenc"])
assert plan["engine"] == "copy"
assert {s["codec_type"] for s in probe(target)["streams"]} == {"video"}
with_session = NativeSession()
try:
hello = with_session.call("hello")
summary["native"]["hello"] = hello["gpu"]
if args.online:
url = "https://www.youtube.com/watch?v=jNQXAC9IVRw"
info = with_session.call("inspect", {"url": url})
(artifacts / "sample-metadata.json").write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
separated = [f for f in info["videos"] if not f["hasAudio"]]
assert separated and info["audios"], "Separate tracks required for this check"
chosen = sorted(separated, key=lambda f: (f["height"], f["fps"]))[0]
payload = {**defaults, "url": url, "folder": str(media_dir), "videoId": chosen["id"], "audioId": "best", "filename": "YouTube-test-HEVC", "codec": "hevc"}
result = with_session.download(payload)
downloaded = probe(result["path"])
assert {s["codec_type"] for s in downloaded["streams"]} >= {"video", "audio"}
assert result["engine"] == "nvenc"
summary["native"]["videoPlusAudio"] = result
result = with_session.download({**payload, "mode": "audio", "container": "mp3", "filename": "YouTube-test-audio"})
assert {s["codec_type"] for s in probe(result["path"])["streams"]} == {"audio"}
summary["native"]["audioOnly"] = result
result = with_session.download({**payload, "mode": "video_only", "container": "mkv", "codec": "copy", "filename": "YouTube-test-video-only"})
assert {s["codec_type"] for s in probe(result["path"])["streams"]} == {"video"}
summary["native"]["videoOnly"] = result
long_url = "https://www.youtube.com/watch?v=aircAruvnKk"
cancel_payload = {**payload, "url": long_url, "videoId": "best", "filename": "cancelled-test"}
job_id = with_session.call("download", cancel_payload)["jobId"]
while True:
message = with_session.next()
job = with_session.jobs.get(job_id, {})
if job.get("stage") == "downloading": break
if job.get("status") == "error": raise AssertionError(job)
with_session.call("cancel", {"jobId": job_id})
deadline = time.monotonic() + 8
while time.monotonic() < deadline and list(media_dir.glob(".ytdl-*")):
time.sleep(0.1)
assert not list(media_dir.glob(".ytdl-*")), "Cancelled job left temporary files"
assert not list(media_dir.glob("cancelled-test.*")), "Cancelled file was published"
summary["native"]["cancel"] = "passed; process tree and temporary files removed"
finally:
with_session.close()
(artifacts / "verification.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print("Integration checks passed.", flush=True)
if __name__ == "__main__": main()
+89
View File
@@ -0,0 +1,89 @@
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';
// This test loads the real YouTube website. No routes or page fixtures.
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-live-shorts-'));
const context = await chromium.launchPersistentContext(profile,{
headless:true, viewport:{width:2160,height:1100},
executablePath:'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
ignoreDefaultArgs:['--disable-extensions'], args:['--enable-unsafe-extension-debugging','--mute-audio'],
});
const cards = 'ytm-shorts-lockup-view-model, yt-shorts-lockup-view-model, ytd-reel-item-renderer';
try {
const cdp = await context.browser().newBrowserCDPSession();
const {id} = await cdp.send('Extensions.loadUnpacked',{path:path.join(root,'extension')});
const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');
await worker.evaluate(() => chrome.storage.local.set({onboarding:{state:'complete',version:1}}));
const page = await context.newPage();
const errors=[]; page.on('pageerror',error => errors.push(error.message));
console.log('Loading real YouTube search in Edge with the actual extension.');
await page.goto(process.env.YTDL_LIVE_URL || 'https://www.youtube.com/results?search_query=subnautica+shorts',{waitUntil:'domcontentloaded',timeout:60000});
console.log('YouTube document loaded:',page.url());
const consent=page.getByRole('button',{name:/Reject all|Отклонить все/i}).first();
if(await consent.isVisible()) await consent.click();
await page.locator(cards).first().waitFor({timeout:60000});
const first=page.locator(cards).first(); await first.scrollIntoViewIfNeeded();
await page.waitForTimeout(1000);
const snapshot=()=>page.evaluate(selector => {
const box=node=>node ? {...node.getBoundingClientRect().toJSON()} : null;
const inspect=node=>({tag:node.tagName,class:node.className,display:getComputedStyle(node).display,
overflowX:getComputedStyle(node).overflowX,overflowY:getComputedStyle(node).overflowY,rect:box(node),offsetWidth:node.offsetWidth});
return {url:location.href,viewport:{width:innerWidth,height:innerHeight},cards:[...document.querySelectorAll(selector)].slice(0,6).map(card=>{
const host=document.querySelector(`[data-ytdl-preview-for="${card.dataset.ytdlPreview}"]`);
let ancestors=[];for(let element=card; element;element=element.parentElement)ancestors.push(inspect(element));
return {html:card.outerHTML.slice(0,16000),media:[...card.querySelectorAll('img,video')].map(inspect),ancestors,control:host ? {style:host.getAttribute('style'),rect:box(host),buttons:[...host.shadowRoot.querySelectorAll('button')].map(inspect)} : null};
}),videos:[...document.querySelectorAll('video')].map(video=>({time:video.currentTime,paused:video.paused,rect:box(video)}))};
},cards);
const before=await snapshot();
await fs.writeFile(path.join(artifacts,'live-shorts-before.json'),JSON.stringify(before,null,2));
await page.screenshot({path:path.join(artifacts,'live-shorts-before.png')});
console.log('Actual Shorts cards:',before.cards.length,'Controls:',before.cards.map(card=>card.control?.style || 'missing'));
if(process.env.YTDL_LIVE_DIAGNOSTIC==='1') {
console.log('Live DOM diagnostics saved.');
} else {
const key=await first.getAttribute('data-ytdl-preview');
assert.ok(key,'Real Shorts card must have extension controls');
const host=page.locator(`[data-ytdl-preview-for="${key}"]`);
await host.locator('.download').waitFor({state:'visible',timeout:15000});
const thumbnail=first.locator('a').filter({has:first.locator('img')}).first();
const target=await thumbnail.count() ? thumbnail : first;
await target.hover();
await page.waitForFunction(() => [...document.querySelectorAll('video')].some(video =>
!video.paused && video.currentTime > 1 && video.getBoundingClientRect().width > 0),{},{timeout:20000});
const after=await snapshot();
await fs.writeFile(path.join(artifacts,'live-shorts-hover.json'),JSON.stringify(after,null,2));
await page.screenshot({path:path.join(artifacts,'live-shorts-hover.png')});
const shelf=first.locator('xpath=ancestor::*[self::grid-shelf-view-model or self::ytd-rich-shelf-renderer or self::ytd-reel-shelf-renderer][1]');
if(await shelf.count()) await shelf.screenshot({path:path.join(artifacts,'live-shorts-proof.png')});
assert.ok(after.videos.some(video=>!video.paused && video.time>1 && video.rect.width>0),'Real YouTube preview video must actually play');
await host.locator('.download').waitFor({state:'visible'});
assert.equal(await host.locator('.settings').isVisible(),true);
const hit=await host.evaluate(node=>{const r=node.getBoundingClientRect();return document.elementFromPoint(r.left+17,r.top+17)===node;});
assert.equal(hit,true,'Real hover player must not cover the download button');
await host.locator('.settings').click();
await page.locator('#ytdl-menu iframe').waitFor({state:'visible'});
const menu=page.frameLocator('#ytdl-menu iframe');
await menu.locator('#helper-panel:not([data-state="checking"]):not([data-state="idle"])').waitFor({timeout:90000});
const helperStatus=await menu.locator('#helper-panel').getAttribute('data-state');
if(helperStatus==='running') assert.equal(await menu.locator('.dependency-row[data-status="installed"]').count(),5);
else assert.equal(await menu.locator('.dependency-row[data-status="installed"]').count(),0,'Unavailable helper must not retain ready component indicators');
assert.ok(!/disconnected port object/i.test(await menu.locator('#helper-message').textContent()));
console.log('Embedded menu actual helper state:',helperStatus,await menu.locator('#helper-message').textContent());
await fs.writeFile(path.join(artifacts,'live-shorts-result.json'),JSON.stringify({
url:page.url(),extensionId:id,version:JSON.parse(await fs.readFile(path.join(root,'extension','manifest.json'))).version,
actualPreviewPlaying:true,controlsVisible:true,downloadHitTest:true,settingsOpened:true,embeddedConnectionModuleLoaded:true,embeddedHelperStatus:helperStatus,
},null,2));
console.log('Live Shorts passed: actual preview playback, visible controls before/after hover, hit testing, settings click and actual helper state in the embedded menu.');
}
await fs.writeFile(path.join(artifacts,'live-shorts-errors.json'),JSON.stringify(errors,null,2));
} finally {
await context.close();
if(path.dirname(path.resolve(profile))===path.resolve(os.tmpdir()) && path.basename(profile).startsWith('ytdl-live-shorts-')) await fs.rm(profile,{recursive:true,force:true});
}
+112
View File
@@ -0,0 +1,112 @@
import test from "node:test";
import assert from "node:assert/strict";
test("native lifecycle recovers stale ports and publishes install/remove/update state to all menus", async () => {
const ports = [], listeners = {}, storage = {}, pending = new Map(), updates = [];
let installed = false;
let helperVersion = "1.3.2";
let componentsReady = true;
const noEvent = { addListener() {} };
const runtime = { id: "test-id", getManifest: () => ({ version: "1.4.5" }), getURL: name => `chrome-extension://test-id/${name}`,
onConnect: { addListener: listener => { listeners.connect = listener; } }, onMessage: noEvent,
connectNative() {
let receive, disconnected;
const port = { sent: [], dead: false,
onMessage: { addListener: listener => { receive = listener; } },
onDisconnect: { addListener: listener => { disconnected = listener; } },
disconnect() { this.dead = true; queueMicrotask(() => disconnected()); },
emit: message => receive(message),
postMessage(message) {
if (this.dead) throw new Error("Attempting to use a disconnected port object");
this.sent.push(message);
if (!installed) {
queueMicrotask(() => { this.dead = true; runtime.lastError = { message: "Specified native messaging host not found." }; disconnected(); delete runtime.lastError; });
return;
}
const result = message.action === "hello" ? { version: helperVersion, ready: componentsReady, gpu: "Test GPU" }
: message.action === "ping" ? { alive: true }
: message.action === "helper_uninstall" ? { removed: true }
: message.action === "helper_update" ? { jobId: "update-job" } : {};
if (message.action === "helper_uninstall") installed = false;
queueMicrotask(() => receive({ id: message.id, ok: true, result }));
},
};
ports.push(port); return port;
},
};
globalThis.chrome = { runtime,
storage: { local: { get: async () => storage, set: async values => Object.assign(storage, values) } },
notifications: { clear: async () => {}, create: async () => {}, update: async () => true, onButtonClicked: noEvent },
tabs: { sendMessage: async () => {} },
action: { onClicked: noEvent, setBadgeText() {}, setBadgeBackgroundColor() {} },
};
await import("../extension/background.js?native-lifecycle-test");
let send;
const attach = collect => listeners.connect({ name: "ytdl-ui", sender: { id: runtime.id, url: runtime.getURL("downloader.html") },
onDisconnect: noEvent, onMessage: { addListener: listener => { send = listener; } },
postMessage(message) {
if (message.id) { pending.get(message.id)?.(message); pending.delete(message.id); }
else collect.push(message);
},
});
const otherMenu = []; attach(otherMenu); attach(updates);
const call = (action, payload = {}) => new Promise(resolve => {
const id = crypto.randomUUID(); pending.set(id, resolve); send({ id, action, payload });
});
assert.equal((await call("helper_start")).ok, false);
assert.equal(updates.at(-1).helper.status, "missing");
installed = true;
assert.equal((await call("helper_status")).result.status, "running", "Polling discovers a newly installed helper");
ports.at(-1).dead = true;
assert.equal((await call("helper_start")).ok, true, "One click must recover a stale native port before its disconnect callback");
const fresh = ports.at(-1);
const speedJob = { id: "speed-dropout", status: "running", stage: "downloading", track: "video", speed: 4.2 * 1048576 };
fresh.emit({ event: "job", job: speedJob });
await new Promise(resolve => setImmediate(resolve));
fresh.emit({ event: "job", job: { ...speedJob, speed: null } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(updates.filter(message => message.job?.id === speedJob.id).at(-1).job.speed, speedJob.speed, "Native progress normalizes a missing speed before publishing to menus and notifications");
fresh.emit({ event: "job", job: { ...speedJob, track: "audio", speed: null } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(updates.filter(message => message.job?.id === speedJob.id).at(-1).job.speed, null, "A new audio track must not inherit the video's speed");
fresh.emit({ event: "job", job: { ...speedJob, status: "cancelled", stage: "cancelled", speed: null } });
await new Promise(resolve => setImmediate(resolve));
ports.at(-2).disconnect(); await new Promise(resolve => setImmediate(resolve));
assert.equal((await call("helper_status")).result.status, "running", "Old disconnect must not invalidate the replacement");
assert.equal((await call("helper_remove")).ok, true);
assert.equal(updates.at(-1).helper.status, "missing");
assert.equal(otherMenu.at(-1).helper.status, "missing", "Removal updates every open menu");
assert.equal((await call("helper_status")).result.status, "missing");
installed = true;
assert.equal((await call("helper_start")).ok, true);
await call("helper_update", { title: "Обновление компонентов" });
const updatingPort = ports.at(-1);
updatingPort.emit({ event: "job", job: { id: "update-job", kind: "update", status: "complete", stage: "complete", result: { restartRequired: true } } });
await new Promise(resolve => setTimeout(resolve, 20));
assert.notEqual(ports.at(-1), updatingPort);
assert.equal((await call("helper_status")).result.status, "running", "Component update reconnects and republishes measured capabilities");
componentsReady = false;
ports.at(-1).emit({ event: "job", job: { id: "broken-component", kind: "dependency", status: "error", stage: "error", error: "Компонент недоступен" } });
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal((await call("state")).result.helper.hardware.ready, false, "A failed component operation refreshes readiness");
componentsReady = true;
ports.at(-1).emit({ event: "job", job: { id: "restored-component", kind: "dependency", status: "complete", stage: "complete" } });
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal((await call("state")).result.helper.hardware.ready, true, "Restoring a component refreshes readiness without reload");
assert.ok(fresh.sent.some(message => message.action === "helper_uninstall"));
for (const [version, update] of [["1.3.0", "helper"], ["1.3.3", "extension"]]) {
helperVersion = version;
ports.at(-1).disconnect();
await new Promise(resolve => setImmediate(resolve));
assert.equal((await call("helper_start")).ok, false);
const state = (await call("state")).result.helper;
assert.equal(state.status, "incompatible");
assert.equal(state.compatibility.update, update);
assert.equal(state.hardware.version, version);
const port = ports.at(-1);
assert.equal((await call("download", { url: "https://youtu.be/jNQXAC9IVRw" })).ok, false, 'Incompatible versions cannot launch a download');
assert.ok(!port.sent.some(message => message.action === "download"));
}
helperVersion = "1.3.2";
assert.equal((await call("helper_status")).result.status, "running", 'Version repair is detected by the Check button without reloading');
});
+97
View File
@@ -0,0 +1,97 @@
"""Exercise the real offline installer with unusable external tools and proxies."""
import ctypes
import json
import os
import subprocess
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ARTIFACTS = ROOT / "test-artifacts"
ARTIFACTS.mkdir(exist_ok=True)
INSTALLER = ROOT / "dist/YouTubeDL-Helper-Setup.exe"
environment = {**os.environ, "PATH": str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32"),
"PYTHONHOME": "C:/NONEXISTENT", "PYTHONPATH": "C:/NONEXISTENT", "PYTHONNOUSERSITE": "1",
"HTTP_PROXY": "http://127.0.0.1:9", "HTTPS_PROXY": "http://127.0.0.1:9", "ALL_PROXY": "http://127.0.0.1:9"}
def run(*args):
result = subprocess.run([str(item) for item in args], env=environment, capture_output=True,
encoding="utf-8", errors="replace", timeout=180)
if result.returncode:
raise RuntimeError(json.dumps({"code": result.returncode, "stdout": result.stdout[-500:], "stderr": result.stderr[-2500:]}))
return result
def picker(manager, folder):
import base64
encoded = base64.b64encode(json.dumps(str(folder)).encode()).decode()
process = subprocess.Popen([str(manager), "--pick-folder", "--initial-folder", encoded], env=environment,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=subprocess.CREATE_NO_WINDOW)
user32 = ctypes.windll.user32
callback_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
dialogs = []
def callback(window, unused):
pid = ctypes.c_ulong(); user32.GetWindowThreadProcessId(window, ctypes.byref(pid))
if pid.value == process.pid:
name = ctypes.create_unicode_buffer(256); user32.GetClassNameW(window, name, 256)
if name.value == "#32770" and user32.IsWindowVisible(window): dialogs.append(window)
return True
deadline = time.monotonic() + 15
handler = callback_type(callback)
while not dialogs and time.monotonic() < deadline:
user32.EnumWindows(handler, 0); time.sleep(0.1)
assert dialogs, "The bundled Windows folder picker must open"
# Select its preselected existing folder, including Unicode in the path.
user32.PostMessageW(ctypes.c_void_p(dialogs[0]), 0x0111, 1, 0) # WM_COMMAND / IDOK
output, errors = process.communicate(timeout=15)
assert process.returncode == 0, errors.decode("utf-8", errors="replace")
assert Path(json.loads(output)) == folder
with tempfile.TemporaryDirectory(prefix="offline-bundle-", dir=ARTIFACTS) as temporary:
parent = Path(temporary)
application = parent / "Helper"
legacy = application / ".runtime"; legacy.mkdir(parents=True)
(legacy / "completed-jobs.json").write_text("[]")
first = run(INSTALLER, "--silent", "--no-register", "--target-root", application)
assert (application / "data/completed-jobs.json").read_text() == "[]"
marker = json.loads((application / "installation.json").read_text(encoding="utf-8-sig"))
release = Path(marker["current"])
python = release / "runtime/python/python.exe"
measured = json.loads(run(python, "-I", "-B", "-c",
"import json,sys; from host.media import capabilities,tool; print(json.dumps({'python':sys.executable,'path':sys.path,'hardware':capabilities(),'tools':{k:tool(k) for k in ('node','ffmpeg','ffprobe')}}))").stdout)
assert measured["hardware"]["ready"]
assert measured["hardware"]["bundle"]["mode"] == "autonomous"
assert {"h264", "hevc", "av1"} <= set(measured["hardware"]["nvenc"]), measured["hardware"]
assert Path(measured["python"]).is_relative_to(release)
assert all(Path(path).is_relative_to(release) for path in measured["tools"].values())
assert all(Path(path).is_relative_to(release) for path in measured["path"])
picker(application / "YouTubeDL-Manager.exe", ARTIFACTS)
preserved = release / "user-kept.mp4"; preserved.write_bytes(b"user file")
history = application / "data/completed-jobs.json"; history.parent.mkdir(exist_ok=True); history.write_text("[]")
# Reinstall beside the running copy; an old DLL handle must not prevent repair.
handle = (release / "runtime/python/python313.dll").open("rb")
try: run(INSTALLER, "--silent", "--no-register", "--target-root", application)
finally: handle.close()
current = Path(json.loads((application / "installation.json").read_text(encoding="utf-8-sig"))["current"])
assert current != release and current.is_dir()
manager = application / "YouTubeDL-Manager.exe"
run(manager, "--uninstall", "--silent", "--target-root", application)
assert preserved.read_bytes() == b"user file" and history.read_text() == "[]"
assert not current.exists() and not python.exists()
deadline = time.monotonic() + 15
while manager.exists() and time.monotonic() < deadline: time.sleep(0.1)
assert not manager.exists(), "The uninstaller must remove its own installed executable"
# Payload damage must be rejected before registering or installing any tools.
damaged = parent / "damaged.exe"
payload = bytearray(INSTALLER.read_bytes()); offset = payload.index(b"PK\x03\x04"); payload[offset + 100] ^= 1
damaged.write_bytes(payload)
invalid = subprocess.run([str(damaged), "--silent", "--no-register", "--target-root", str(parent / "Invalid")],
env=environment, capture_output=True, encoding="utf-8", timeout=60)
assert invalid.returncode != 0 and "Контрольная сумма" in invalid.stderr, invalid.stderr
(ARTIFACTS / "offline-bundle.json").write_text(json.dumps({"offlineInstallation": "passed", "isolatedRuntime": "passed",
"folderPicker": "passed", "repairWhileOldFilesOpen": "passed", "uninstallPreservesUserMedia": "passed", "corruptedPayload": "rejected",
"versions": measured["hardware"]["dependencies"], "nvenc": measured["hardware"]["nvenc"]}, ensure_ascii=False, indent=2), encoding="utf-8")
print("Offline bundle passed: isolated runtime, blocked proxies, Windows picker, NVENC, repair, scoped uninstall and damaged-payload rejection.")
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
await fs.mkdir(artifacts, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
const context = await browser.newContext({ viewport: { width: 520, height: 820 }, reducedMotion: "reduce" });
await context.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
await context.route("https://ui.ytdl.test/**", async route => {
const name = new URL(route.request().url()).pathname.slice(1);
if (!["downloader.html", "downloader.js", "onboarding.js", "ui-connection.js", "theme-init.js", "styles.css", "shared.js", "session.js"].includes(name)) return route.abort();
return 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 errors = [];
context.on("page", page => page.on("pageerror", error => errors.push(error.message)));
try {
const page = await context.newPage();
await page.addInitScript(() => { window.__firstRun = true; window.__helperMissing = true; });
await page.goto("https://ui.ytdl.test/downloader.html?theme=dark");
await page.locator("#setup[open]").waitFor();
await page.screenshot({ path: path.join(artifacts, "onboarding-welcome.png"), animations: "disabled" });
await page.locator("#setup-next").click();
await page.locator('#setup-helper[data-state="missing"]').waitFor();
assert.equal(await page.locator("#setup-next").isDisabled(), true);
assert.equal(await page.locator("#setup-install").isVisible(), true);
assert.equal(await page.locator("#setup-install").getAttribute("href"), "https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest");
assert.equal(await page.locator("#setup-install").getAttribute("target"), "_blank");
assert.equal(await page.locator("#setup-install").getAttribute("download"), null);
assert.equal(await page.locator("#setup-install-steps li").count(), 3);
await page.screenshot({ path: path.join(artifacts, "onboarding-install.png"), animations: "disabled" });
// A connected helper with a broken component is insufficient to continue.
await page.evaluate(() => { window.__helperMissing = false; window.__dependencyMissing = true; });
await page.locator("#setup-check").click();
await page.locator("#setup-check:not([disabled])").waitFor();
assert.equal(await page.locator("#setup-next").isDisabled(), true);
assert.match(await page.locator("#setup-install").textContent(), /восстановления/);
await page.evaluate(() => { window.__dependencyMissing = false; });
await page.locator("#setup-check").click();
await page.locator("#setup-next:not([disabled])").waitFor();
assert.match(await page.locator("#setup-helper-message").textContent(), /5\/5/);
assert.match(await page.locator("#setup-gpu").textContent(), /RTX 5080.*NVENC/);
await page.locator("#setup-updates").uncheck();
await page.locator("#setup-next:not([disabled])").waitFor();
assert.equal(await page.locator("#setup-updates").isChecked(), false);
await page.locator("#setup-next").click();
await page.locator("#setup-folder").fill("");
await page.locator("#setup-next").click();
assert.match(await page.locator("#setup-error").textContent(), /Выберите папку/);
await page.locator("#setup-pick-folder").click();
await page.locator("#setup-pick-folder:not([disabled])").waitFor();
assert.equal(await page.locator("#setup-folder").inputValue(), "D:\\Videos\\YouTube");
await page.locator("#setup-pick-work-folder").click();
await page.locator("#setup-pick-work-folder:not([disabled])").waitFor();
assert.equal(await page.locator("#setup-work-folder").inputValue(), "D:\\Videos\\YouTube");
await page.locator("#setup-work-folder").fill("F:\\Scratch");
await page.locator("#setup-mode").selectOption("audio");
assert.equal(await page.locator("#setup-quality-field").isVisible(), false);
await page.locator("#setup-format").selectOption("mp3");
await page.locator("#setup-mode").selectOption("video");
await page.locator("#setup-format").selectOption("mkv");
await page.locator("#setup-quality").selectOption("1080");
await page.screenshot({ path: path.join(artifacts, "onboarding-settings.png"), animations: "disabled" });
await page.locator("#setup-next").click();
await page.locator('[data-setup-step="3"]').waitFor({ state: "visible" });
assert.match(await page.locator("#setup-saved").textContent(), /D:\\Videos\\YouTube/);
await page.locator("#setup-next").click();
await page.locator("#setup").waitFor({ state: "hidden" });
const result = await page.evaluate(() => window.__fixtureStorage);
assert.equal(result.onboarding.state, "complete");
assert.equal(result.preferences.videoContainer, "mkv");
assert.equal(result.preferences.audioContainer, "mp3");
assert.equal(result.preferences.quality.height, 1080);
assert.equal(result.preferences.workFolder, "F:\\Scratch");
assert.equal(result.preferences.engine, "auto");
await page.locator("#open-setup").click();
await page.locator("#setup[open]").waitFor();
await page.keyboard.press("Escape");
await page.locator("#setup").waitFor({ state: "hidden" });
assert.equal(await page.evaluate(() => window.__fixtureStorage.onboarding.state), "skipped");
await page.close();
const narrow = await context.newPage();
await narrow.setViewportSize({ width: 340, height: 560 });
await narrow.addInitScript(() => { window.__firstRun = true; });
await narrow.goto("https://ui.ytdl.test/downloader.html?theme=light");
await narrow.locator("#setup[open]").waitFor();
await narrow.locator("#setup-next").click();
await narrow.locator("#setup-next:not([disabled])").waitFor();
const box = await narrow.locator("#setup").boundingBox();
assert.ok(box.x >= 0 && box.x + box.width <= 340 && box.y >= 0 && box.y + box.height <= 560);
assert.equal(await narrow.locator("#setup-next").isVisible(), true);
await narrow.screenshot({ path: path.join(artifacts, "onboarding-light-narrow.png"), animations: "disabled" });
await narrow.locator("#setup-skip").click();
await narrow.locator("#setup").waitFor({ state: "hidden" });
assert.equal(await narrow.evaluate(() => window.__fixtureStorage.onboarding.state), "skipped");
await narrow.close();
const existing = await context.newPage();
await existing.addInitScript(() => { window.__firstRun = true; window.__fixtureStorage.preferences = { folder: "D:\\Existing", videoContainer: "webm" }; });
await existing.goto("https://ui.ytdl.test/downloader.html");
await existing.locator('#helper-panel[data-state="running"]').waitFor();
assert.equal(await existing.locator("#setup").evaluate(dialog => dialog.open), false, "Configured profiles are not interrupted by a new guide");
assert.equal(await existing.locator("#folder").inputValue(), "D:\\Existing");
assert.deepEqual(errors, []);
console.log("Onboarding passed: first launch, missing/broken helper, real-state gating, update toggle, folder validation, video/audio defaults, skip, reopen, Escape, existing profiles and narrow light theme.");
} finally { await browser.close(); }
+176
View File
@@ -0,0 +1,176 @@
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 });
}
+93
View File
@@ -0,0 +1,93 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readSession, SESSION_PERMISSION } from "../extension/session.js";
const sampleCookie = value => ({ name: "SAPISID", value, domain: ".youtube.com", path: "/", secure: true, httpOnly: true, hostOnly: false });
test("session access needs both opt-in and permission and does not return secrets to the UI", async () => {
let enabled = false, permission = false, reads = 0;
globalThis.chrome = {
storage: { local: { get: async () => ({ youtubeSession: enabled }) } },
permissions: { contains: async () => permission },
cookies: {
getAllCookieStores: async () => [{ id: "current-profile", tabIds: [42] }],
getAll: async options => {
reads++; assert.equal(options.domain, "youtube.com"); assert.equal(options.storeId, "current-profile");
return [sampleCookie("synthetic-session"), { ...sampleCookie("other"), domain: ".google.com" },
{ ...sampleCookie("evil"), domain: "youtube.com.evil.test" }, { ...sampleCookie("expired"), expirationDate: 1 },
{ ...sampleCookie("partitioned"), partitionKey: { topLevelSite: "https://another.test" } }];
},
},
};
assert.equal((await readSession(42)).status, "disabled"); assert.equal(reads, 0);
enabled = true;
assert.equal((await readSession(42)).status, "permission_required"); assert.equal(reads, 0);
permission = true;
const status = await readSession(42);
assert.equal(status.status, "connected"); assert.ok(!JSON.stringify(status).includes("synthetic-session"));
const authenticated = await readSession(42, true);
assert.equal(authenticated.cookies.length, 1); assert.equal(authenticated.cookies[0].value, "synthetic-session");
await assert.rejects(readSession(999, true), /профиль вкладки/);
});
test("background passes fresh YouTube cookies to both worker actions and never persists them", async () => {
const storage = {}, requests = [], listeners = {}, pendingUi = new Map();
let permission = false, cookieValue = "synthetic-original", cookieReads = 0, nativeListener;
const noEvent = { addListener() {} };
const native = {
onMessage: { addListener(listener) { nativeListener = listener; } }, onDisconnect: noEvent, disconnect() {},
postMessage(message) {
requests.push(message);
const result = message.action === "hello" ? { version: "1.3.2", ready: true, supportsBrowserCookies: true } : message.action === "ping" ? { alive: true }
: message.action === "download" ? { jobId: "auth-test-job" } : { title: "Synthetic video", videos: [], audios: [] };
queueMicrotask(() => nativeListener({ id: message.id, ok: true, result }));
},
};
globalThis.chrome = {
runtime: { id: "test-id", getManifest: () => ({ version: "1.4.5" }), getURL: name => `chrome-extension://test-id/${name}`, connectNative: () => native,
onConnect: { addListener(listener) { listeners.connect = listener; } }, onMessage: noEvent },
permissions: { contains: async () => permission, remove: async input => { assert.deepEqual(input, SESSION_PERMISSION); permission = false; return true; } },
cookies: { getAllCookieStores: async () => [{ id: "0", tabIds: [42] }], getAll: async () => { cookieReads++; return [sampleCookie(cookieValue)]; } },
storage: { local: { get: async key => Object.fromEntries((Array.isArray(key) ? key : [key]).map(name => [name, storage[name]])),
set: async values => Object.assign(storage, structuredClone(values)) } },
notifications: { clear: async () => {}, create: async () => {}, onButtonClicked: noEvent },
tabs: { sendMessage: async () => {} }, action: { onClicked: noEvent, setBadgeText() {}, setBadgeBackgroundColor() {} },
};
await import("../extension/background.js?session-test");
let uiListener;
listeners.connect({ name: "ytdl-ui", sender: { id: "test-id", url: "chrome-extension://test-id/downloader.html" },
onDisconnect: noEvent, onMessage: { addListener(listener) { uiListener = listener; } },
postMessage(message) { if (message.id) { const done = pendingUi.get(message.id); pendingUi.delete(message.id); done(message); } } });
const call = (action, payload = {}) => new Promise(resolve => {
const id = crypto.randomUUID(); pendingUi.set(id, resolve); uiListener({ id, action, payload });
});
assert.equal((await call("hello")).ok, true);
assert.equal((await call("session_enable", { enabled: true, tabId: 42 })).ok, false);
const url = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
await call("inspect", { url, tabId: 42, browserCookies: [sampleCookie("caller-injected")] });
assert.equal(cookieReads, 0); assert.ok(!requests.at(-1).payload.browserCookies);
permission = true;
const enabled = await call("session_enable", { enabled: true, tabId: 42 });
assert.equal(enabled.result.status, "connected"); assert.ok(!JSON.stringify(enabled).includes(cookieValue));
await call("inspect", { url, tabId: 42 });
assert.equal(requests.at(-1).payload.browserCookies[0].value, cookieValue);
const inspectionRequests = requests.filter(request => request.action === "inspect").length;
await call("inspect", { url, tabId: 42 });
assert.equal(requests.filter(request => request.action === "inspect").length, inspectionRequests, "Reuse a recently inspected video's quality without another YouTube request");
await call("inspect", { url, tabId: 42, refresh: true });
assert.equal(requests.filter(request => request.action === "inspect").length, inspectionRequests + 1, "Explicit refresh bypasses the summary cache");
cookieValue = "synthetic-rotated";
await call("download", { url, tabId: 42, title: "Test", folder: "D:/Videos" });
assert.equal(requests.at(-1).payload.browserCookies[0].value, cookieValue);
nativeListener({ event: "job", job: { id: "auth-test-job", status: "complete", stage: "complete", result: { title: "Test", filename: "test.mp4", path: "D:/Videos/test.mp4" } } });
await new Promise(resolve => setTimeout(resolve, 20));
assert.ok(!JSON.stringify(storage).includes("synthetic-")); assert.ok(!JSON.stringify(storage).includes("browserCookies"));
await call("session_enable", { enabled: false, tabId: 42 });
const readsBefore = cookieReads;
await call("inspect", { url, tabId: 42 });
assert.equal(cookieReads, readsBefore); assert.ok(!requests.at(-1).payload.browserCookies);
// An expired/removed permission must still allow ordinary public downloads.
storage.youtubeSession = true;
assert.equal((await call("inspect", { url, tabId: 42, refresh: true })).ok, true);
assert.ok(!requests.at(-1).payload.browserCookies);
});
+73
View File
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createServer } from "node:http";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const version = JSON.parse(await fs.readFile(path.join(root,"extension/manifest.json"),"utf8")).version;
const artifacts = path.join(root, "test-artifacts");
await fs.mkdir(artifacts, { recursive: true });
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url, "http://localhost");
if (!url.pathname.startsWith("/YouTubeDL/")) { response.writeHead(404).end(); return; }
const relative = decodeURIComponent(url.pathname.slice("/YouTubeDL/".length)) || "index.html";
const file = path.resolve(root,"site",relative);
if (!file.startsWith(path.resolve(root,"site") + path.sep)) { response.writeHead(400).end(); return; }
const type = {".html":"text/html; charset=utf-8",".js":"text/javascript",".css":"text/css",".png":"image/png",".svg":"image/svg+xml"}[path.extname(file)] || "application/octet-stream";
response.writeHead(200,{"Content-Type":type});response.end(await fs.readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0,"127.0.0.1",resolve));
const local = `http://127.0.0.1:${server.address().port}/YouTubeDL/`;
const target = process.env.YTDL_SITE_URL || local;
let browser;
try {
browser = await chromium.launch({ headless:true, executablePath:process.env.YTDL_TEST_BROWSER || "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
const context = await browser.newContext({ viewport:{width:1440,height:1050}, reducedMotion:"reduce", colorScheme:"light" });
const page = await context.newPage(), failures=[];
page.on("pageerror",error => failures.push(error.message));
page.on("response",response => { if (response.url().startsWith(target) && response.status() >= 400) failures.push(`${response.status()} ${response.url()}`); });
await page.goto(target,{waitUntil:"networkidle"});
assert.equal(await page.locator("h1").count(),1);
await page.locator(".hero .primary").click();
assert.equal(new URL(page.url()).hash,"#install");
assert.ok(await page.locator(".download-grid").isVisible());
assert.equal(await page.locator('[data-download="extension"]').getAttribute("href"),`https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/download/v${version}/YouTubeDL-${version}.zip`);
assert.equal(await page.locator('[data-download="helper"]').getAttribute("href"),"https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/download/v1.4.5/YouTubeDL-Helper-Setup.exe");
await page.locator('[data-screenshot="light"]').click();
await page.locator("#menu-screenshot").evaluate(image => image.decode());
assert.match(await page.locator("#menu-screenshot").getAttribute("src"), /menu-light\.png$/);
await page.locator('[data-screenshot="dark"]').click();
await page.locator("#menu-screenshot").evaluate(image => image.decode());
await page.locator("#theme-toggle").click();
assert.equal(await page.locator("html").getAttribute("data-theme"),"dark");
await page.reload({waitUntil:"networkidle"});
assert.equal(await page.locator("html").getAttribute("data-theme"),"dark");
await page.locator("#theme-toggle").click();
await page.evaluate(() => scrollTo(0,0));
await page.locator(".hero").screenshot({path:path.join(artifacts,"site-hero.png")});
await page.locator(".download-grid").screenshot({path:path.join(artifacts,"site-downloads.png")});
await page.screenshot({path:path.join(artifacts,"site-desktop.png"),fullPage:true});
for (const width of [320,390,768]) {
await page.setViewportSize({width,height:844});
await page.evaluate(() => scrollTo(0,0));
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")].filter(node => node.getBoundingClientRect().right > innerWidth + 1 && getComputedStyle(node).position !== "absolute").map(node => ({tag:node.tagName,class:node.className,right:Math.round(node.getBoundingClientRect().right)})));
if (await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)) await page.screenshot({path:path.join(artifacts,`site-overflow-${width}.png`),fullPage:true});
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),`No horizontal overflow at ${width}px: ${JSON.stringify(overflow)}`);
assert.ok(await page.locator("h1").isVisible());
await page.locator('nav a[href="#install"]').click();
assert.equal(new URL(page.url()).hash,"#install");
const summary = page.locator(".faqs summary").first();await summary.click();
assert.equal(await page.locator(".faqs details").first().getAttribute("open"),"");
await summary.click();
await page.evaluate(() => scrollTo(0,0));
if(width===390) await page.screenshot({path:path.join(artifacts,"site-mobile.png"),fullPage:true});
}
const images=await page.locator("img").evaluateAll(nodes => nodes.map(image=>({src:image.getAttribute("src"),okay:image.complete&&image.naturalWidth>0})));
assert.ok(images.every(image=>image.okay),JSON.stringify(images));
assert.deepEqual(failures,[]);
await fs.writeFile(path.join(artifacts,"site-check.json"),JSON.stringify({url:target,desktop:"passed",widths:[320,390,768],themes:"passed",screenshots:"passed",downloadLinks:"passed",faq:"passed",errors:failures},null,2));
console.log(`Site passed at ${target}: desktop/mobile, Pages subpath, download links, theme persistence, screenshots, FAQ and no console/HTTP errors.`);
} finally { if(browser)await browser.close();await new Promise(resolve=>server.close(resolve)); }
+22
View File
@@ -0,0 +1,22 @@
param(
[Parameter(Mandatory=$true)][string]$Name,
[Parameter(Mandatory=$true)][string]$ManifestPath,
[switch]$Remove
)
$ErrorActionPreference = 'Stop'
if ($Name -notmatch '^com\.youtubedl\.test_[a-f0-9]{16}$') { throw 'Only isolated test hosts are allowed.' }
$taskManifest = [IO.Path]::GetFullPath($ManifestPath)
$taskKey = "Registry::HKEY_CURRENT_USER\Software\Microsoft\Edge\NativeMessagingHosts\$Name"
if (Test-Path -LiteralPath $taskKey) {
$taskRegistered = (Get-Item -LiteralPath $taskKey).GetValue('')
if ($taskRegistered -and $taskRegistered -ne $taskManifest) { throw 'Test registration belongs to another path.' }
}
if ($Remove) {
if (Test-Path -LiteralPath $taskKey) { Remove-Item -LiteralPath $taskKey }
} else {
$taskData = Get-Content -LiteralPath $taskManifest -Raw | ConvertFrom-Json
if ($taskData.name -ne $Name) { throw 'Wrong test host manifest.' }
New-Item -Path $taskKey -Force | Out-Null
Set-Item -LiteralPath $taskKey -Value $taskManifest
if ((Get-Item -LiteralPath $taskKey).GetValue('') -ne $taskManifest) { throw 'Test registration was not written.' }
}
+228
View File
@@ -0,0 +1,228 @@
import io
import json
import os
import struct
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from host.common import safe_filename, select_streams, validate_download, youtube_url
from host.media import encoding_plan, ffmpeg_command
from host.protocol import MAX_MESSAGE, read_message, write_message
from host.worker import publish_file
from host.main import NativeHost
from host.session import apply_cookies, validate_cookies, public_error
class HostTests(unittest.TestCase):
def test_fragment_access_failure_retries_and_preserves_the_original_error(self):
from unittest.mock import MagicMock
from yt_dlp.utils import DownloadError
from host.worker import download
first, second = MagicMock(), MagicMock()
first.__enter__.return_value = first; second.__enter__.return_value = second
handles = []
def fail(ydl):
# Reproduce a downloader retaining its .part handle during failure.
part = Path(ydl.params["outtmpl"].replace("%(ext)s", "mp4") + ".part")
handles.append(part.open("wb"))
raise DownloadError("HTTP Error 403: Forbidden")
def create_ydl(params):
current = first if not handles else second
current.params = params
current.extract_info.side_effect = lambda *args, **kwargs: fail(current)
return current
with tempfile.TemporaryDirectory() as folder:
try:
with patch("yt_dlp.YoutubeDL", side_effect=create_ydl), patch("host.worker.ydl_options", return_value={}), \
patch("host.worker.time.sleep"), patch("host.worker.emit") as emit:
with self.assertRaisesRegex(DownloadError, "HTTP Error 403"):
download({"url": "https://youtu.be/jNQXAC9IVRw", "folder": folder}, [])
self.assertEqual(len(handles), 2)
self.assertTrue(any("повторяю загрузку" in str(call) for call in emit.call_args_list))
finally:
for handle in handles: handle.close()
def test_transient_youtube_challenge_is_retried_once_without_browser_cookies(self):
from unittest.mock import MagicMock
from yt_dlp.utils import DownloadError
first, second = MagicMock(), MagicMock()
first.__enter__.return_value = first; second.__enter__.return_value = second
first.extract_info.side_effect = DownloadError("Sign in to confirm you’re not a bot")
second.extract_info.return_value = {"id": "jNQXAC9IVRw"}
with patch("yt_dlp.YoutubeDL", side_effect=[first, second]), patch("host.worker.ydl_options", return_value={}), \
patch("host.worker.summarize", side_effect=lambda info: info), patch("host.worker.time.sleep"):
from host.worker import inspect
self.assertEqual(inspect("https://youtu.be/jNQXAC9IVRw")["id"], "jNQXAC9IVRw")
first.cookiejar.set_cookie.assert_not_called(); second.cookiejar.set_cookie.assert_not_called()
def test_browser_session_is_youtube_only_and_never_saved_to_a_cookie_file(self):
from urllib.request import Request
from yt_dlp import YoutubeDL
from yt_dlp.cookies import YoutubeDLCookieJar
cookie = {"name": "SAPISID", "value": "synthetic-cookie", "domain": ".youtube.com", "path": "/", "secure": True, "httpOnly": True}
with patch.object(YoutubeDLCookieJar, "save", side_effect=AssertionError("Cookies must stay in memory")):
with YoutubeDL({"quiet": True}) as ydl:
apply_cookies(ydl, [cookie])
self.assertIsNone(ydl.cookiejar.filename)
request = Request("https://www.youtube.com/watch?v=jNQXAC9IVRw")
ydl.cookiejar.add_cookie_header(request)
self.assertIn("SAPISID=synthetic-cookie", request.get_header("Cookie"))
unrelated = Request("https://www.google.com/")
ydl.cookiejar.add_cookie_header(unrelated)
self.assertIsNone(unrelated.get_header("Cookie"))
for invalid in [{**cookie, "domain": ".google.com"}, {**cookie, "domain": "youtube.com.evil.test"},
{**cookie, "value": "secret\r\nInjected: header"}, {**cookie, "expirationDate": float("nan")}, {**cookie, "name": "bad=name"}]:
with self.assertRaises(ValueError): validate_cookies([invalid])
def test_native_inspection_passes_cookies_only_to_worker_and_not_to_reply(self):
with tempfile.TemporaryDirectory() as folder, patch("host.main.ROOT", Path(folder)):
output = io.BytesIO(); host = NativeHost(output)
cookie = {"name": "SID", "value": "synthetic-session", "domain": ".youtube.com", "path": "/"}
try:
with patch.object(host, "worker", return_value={"title": "Test"}) as worker:
host.handle({"id": "inspect-auth", "action": "inspect", "payload": {"url": "https://youtu.be/jNQXAC9IVRw", "browserCookies": [cookie]}})
self.assertEqual(worker.call_args.args[1]["browserCookies"][0]["value"], cookie["value"])
self.assertNotIn(b"synthetic-session", output.getvalue())
self.assertNotIn("browserCookies", json.dumps(host.jobs))
finally: host.close()
def test_youtube_login_error_is_distinct_from_helper_connection_failure(self):
message = public_error(ValueError("ERROR: Sign in to confirm you’re not a bot"))
self.assertIn("Использовать вход в YouTube", message)
self.assertIn("Помощник работает", message)
self.assertNotIn("cookies-from-browser", message)
self.assertIn("отклонил вход", public_error(ValueError("Sign in to confirm"), True))
def test_youtube_urls_are_canonical_and_do_not_allow_arbitrary_hosts(self):
expected = "https://www.youtube.com/watch?v=jNQXAC9IVRw"
for value in [expected + "&list=other", "https://youtu.be/jNQXAC9IVRw?t=2", "https://www.youtube.com/shorts/jNQXAC9IVRw"]:
self.assertEqual(youtube_url(value), expected)
for value in ["https://youtube.com.evil.test/watch?v=jNQXAC9IVRw", "https://127.0.0.1/watch?v=jNQXAC9IVRw", "https://youtube.com@evil.test/watch?v=jNQXAC9IVRw", "file:///test", "https://www.youtube.com:444/watch?v=jNQXAC9IVRw", "https://www.youtube.com/@channel"]:
with self.assertRaises(ValueError): youtube_url(value)
def test_separate_audio_is_added_and_video_only_stays_single(self):
info = {"formats": [{"format_id": "18", "vcodec": "h264", "acodec": "aac", "height": 360},
{"format_id": "137", "vcodec": "h264", "acodec": "none", "height": 1080},
{"format_id": "140", "vcodec": "none", "acodec": "aac"}]}
base = {"mode": "video", "videoId": "137", "audioId": "best"}
self.assertEqual(select_streams(info, base), "137+140")
self.assertEqual(select_streams(info, {**base, "mode": "video_only"}), "137")
self.assertEqual(select_streams(info, {**base, "videoId": "18"}), "18")
self.assertEqual(select_streams(info, {**base, "mode": "audio"}), "140")
def test_download_does_not_accept_format_selector_injection(self):
with tempfile.TemporaryDirectory() as folder:
base = {"url": "https://youtu.be/jNQXAC9IVRw", "folder": folder, "videoId": "137+bestaudio"}
with self.assertRaises(ValueError): validate_download(base)
self.assertEqual(validate_download({**base, "videoId": "137"})["container"], "mp4")
with self.assertRaises(ValueError): validate_download({**base, "videoId": "137", "container": "exe"})
def test_gpu_is_used_only_when_video_needs_transcoding(self):
media = {"streams": [{"codec_type": "video", "codec_name": "vp9", "pix_fmt": "yuv420p"}, {"codec_type": "audio", "codec_name": "opus"}]}
options = {"mode": "video", "container": "mp4", "codec": "auto", "engine": "auto"}
plan = encoding_plan(options, media, ["h264", "hevc", "av1"])
self.assertEqual((plan["video"], plan["audio"], plan["engine"]), ("h264", "aac", "nvenc"))
copied = encoding_plan({**options, "container": "mkv"}, media, ["h264"])
self.assertEqual((copied["video"], copied["audio"], copied["engine"]), ("copy", "copy", "copy"))
cpu = encoding_plan(options, media, [])
self.assertEqual(cpu["engine"], "cpu")
with self.assertRaises(ValueError): encoding_plan({**options, "engine": "nvenc"}, media, [])
with self.assertRaises(ValueError): encoding_plan({**options, "codec": "copy"}, media, [])
def test_hdr_is_not_silently_converted_to_h264(self):
media = {"streams": [{"codec_type": "video", "codec_name": "vp9", "pix_fmt": "yuv420p10le", "color_transfer": "smpte2084"}, {"codec_type": "audio", "codec_name": "opus"}]}
options = {"mode": "video", "container": "mp4", "codec": "auto", "engine": "auto"}
self.assertEqual(encoding_plan(options, media, ["hevc"])["video"], "hevc")
with self.assertRaises(ValueError): encoding_plan({**options, "codec": "h264"}, media, ["h264"])
def test_amf_requires_measured_codec_and_10bit_support(self):
media = {"streams": [{"codec_type": "video", "codec_name": "vp9", "pix_fmt": "yuv420p"}, {"codec_type": "audio", "codec_name": "opus"}]}
options = {"mode": "video", "container": "mp4", "codec": "hevc", "engine": "auto", "preset": "balanced", "bitrate": 256}
self.assertEqual(encoding_plan(options, media, {"amf": ["hevc"]})["engine"], "amf")
self.assertEqual(encoding_plan(options, media, {"nvenc": ["hevc"], "amf": ["hevc"]})["engine"], "nvenc")
with self.assertRaisesRegex(ValueError, "AMD AMF"):
encoding_plan({**options, "engine": "amf"}, media, {})
media["streams"][0].update(pix_fmt="yuv420p10le", color_transfer="smpte2084")
self.assertEqual(encoding_plan(options, media, {"amf": ["hevc"]})["engine"], "cpu")
plan = encoding_plan({**options, "engine": "amf"}, media, {"amf": ["hevc"], "amf10bit": ["hevc"]})
self.assertTrue(plan["tenBit"])
with patch("host.media.tool", return_value="ffmpeg"):
command = ffmpeg_command("source.mkv", "out.mp4", options, plan)
self.assertIn("hevc_amf", command); self.assertIn("p010le", command); self.assertIn("main10", command)
self.assertIn("d3d11va", command); self.assertNotIn("cuda", command)
def test_audio_and_video_only_output_maps(self):
source = {"streams": [{"codec_type": "video", "codec_name": "h264"}, {"codec_type": "audio", "codec_name": "aac"}]}
options = {"mode": "video_only", "container": "mp4", "codec": "auto", "engine": "auto", "preset": "balanced", "bitrate": 256}
with patch("host.media.tool", return_value="ffmpeg"):
command = ffmpeg_command("source.mkv", "out.mp4", options, encoding_plan(options, source, []))
self.assertIn("-an", command)
self.assertNotIn("0:a:0", command)
audio = encoding_plan({**options, "mode": "audio", "container": "mp3"}, source, [])
self.assertEqual((audio["video"], audio["audio"], audio["engine"]), (None, "libmp3lame", "cpu"))
def test_protocol_uses_utf8_byte_lengths_and_handles_partial_reads(self):
message = {"text": "Русский путь 🎬"}
stream = io.BytesIO()
write_message(stream, message)
self.assertEqual(struct.unpack("<I", stream.getvalue()[:4])[0], len(stream.getvalue()) - 4)
class Partial(io.BytesIO):
def read(self, count=-1): return super().read(min(count, 2))
self.assertEqual(read_message(Partial(stream.getvalue())), message)
self.assertIsNone(read_message(io.BytesIO()))
with self.assertRaises(ValueError): read_message(io.BytesIO(struct.pack("<I", MAX_MESSAGE + 1)))
with self.assertRaises(EOFError): read_message(io.BytesIO(struct.pack("<I", 5) + b"{}"))
def test_filename_is_safe_and_existing_files_are_never_overwritten(self):
self.assertEqual(safe_filename("../CON:bad/name", "video"), "_CON_bad_name")
self.assertEqual(safe_filename("CON", "video"), "_CON")
with tempfile.TemporaryDirectory() as folder:
root = Path(folder); original = root / "video.mp4"; original.write_bytes(b"original")
source = root / "temp.mp4"; source.write_bytes(b"new")
saved = publish_file(source, root, "video", "mp4")
self.assertEqual(saved.name, "video (1).mp4")
self.assertEqual(original.read_bytes(), b"original")
self.assertEqual(saved.read_bytes(), b"new")
def test_dependency_install_refreshes_capabilities_before_reporting_completion(self):
with tempfile.TemporaryDirectory() as folder, patch("host.main.ROOT", Path(folder)):
host = NativeHost(io.BytesIO())
host._capabilities = {"ready": False}
host.jobs["install"] = {"id": "install", "status": "running", "kind": "dependency"}
try:
with patch.object(host, "worker", return_value={"title": "Node.js"}), patch.object(host, "event") as event:
event.side_effect = lambda *args, **kwargs: self.assertIsNone(host._capabilities)
host.run_job("install", {"dependency": "node"}, "dependency_install")
self.assertEqual(event.call_args.kwargs["status"], "complete")
with patch.object(host, "worker", side_effect=ValueError("install failed")), patch.object(host, "event") as event:
host._capabilities = {"ready": False}
event.side_effect = lambda *args, **kwargs: self.assertIsNone(host._capabilities)
host.run_job("install", {"dependency": "node"}, "dependency_install")
self.assertEqual(event.call_args.kwargs["status"], "error")
finally:
host.close()
def test_completed_file_can_be_revealed_after_native_host_restart(self):
with tempfile.TemporaryDirectory() as folder, patch("host.main.ROOT", Path(folder)):
saved = Path(folder) / "finished.mp4"
saved.write_bytes(b"media")
first = NativeHost(io.BytesIO())
first.jobs["completed"] = {"id": "completed", "status": "running", "createdAt": 1}
first.event("completed", status="complete", result={"path": str(saved)})
first.close()
output = io.BytesIO()
restarted = NativeHost(output)
try:
with patch("host.main.subprocess.Popen") as launch:
restarted.handle({"id": "reveal", "action": "reveal", "payload": {"jobId": "completed"}})
output.seek(0)
self.assertTrue(read_message(output)["ok"])
if os.name == "nt":
self.assertEqual(launch.call_args.args[0][-1], str(saved))
finally:
restarted.close()
if __name__ == "__main__": unittest.main()
+205
View File
@@ -0,0 +1,205 @@
import functools
import io
import json
import os
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch
from host.common import validate_download
from host.main import NativeHost
from host.media import NO_WINDOW, ROOT, probe, tool
from host.processes import ProcessTree
from host.worker import convert, download
class ProcessingTests(unittest.TestCase):
def test_audio_conversion_and_video_conversion_report_the_actual_engine(self):
runtime = ROOT / ".runtime"
runtime.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="processing-stages-", dir=runtime) as directory:
source = Path(directory) / "source.mkv"
subprocess.run([tool("ffmpeg"), "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=160x90:rate=24",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "3", "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "libopus", str(source)],
creationflags=NO_WINDOW, stdin=subprocess.DEVNULL, check=True, timeout=30)
media = probe(source)
for codec, stage in [("auto", "audio"), ("h264", "encoding")]:
target = Path(directory) / f"{codec}.mp4"
events = []
options = {"mode": "video", "container": "mp4", "codec": codec, "engine": "auto" if codec == "auto" else "cpu", "preset": "fast", "bitrate": 192}
with patch("host.worker.emit", side_effect=lambda kind, **data: events.append({"type": kind, **data})):
plan = convert(source, target, options, media, {"nvenc": ["h264"]})
self.assertEqual(plan["engine"], "cpu")
self.assertEqual(plan["video"], "copy" if codec == "auto" else "h264")
self.assertTrue(any(event.get("stage") == stage and event.get("engine") == "cpu" for event in events), events)
self.assertFalse(any(event.get("stage") == "merging" for event in events), events)
self.assertTrue(any(event.get("stage") == "finalizing" and event.get("percent") == 99 and not event.get("speed") for event in events), events)
finalizing = next(index for index, event in enumerate(events) if event.get("stage") == "finalizing")
self.assertTrue(all(event.get("stage") == "finalizing" for event in events[finalizing:]), events)
self.assertEqual({s.get("codec_name") for s in probe(target)["streams"]}, {"h264", "aac"})
def test_late_completion_cannot_replace_cancellation(self):
host = NativeHost(io.BytesIO())
try:
host.jobs["late"] = {"id": "late", "status": "running", "stage": "cancelling", "cancelling": True}
host.event("late", status="complete", stage="complete")
self.assertEqual(host.jobs["late"]["status"], "running")
host.event("late", status="cancelled", stage="cancelled", cancelling=False)
host.event("late", status="complete", stage="complete")
self.assertEqual(host.jobs["late"]["status"], "cancelled")
finally: host.close()
def test_work_folder_validation_and_default(self):
with tempfile.TemporaryDirectory() as folder:
payload = {"url": "https://youtu.be/jNQXAC9IVRw", "folder": folder}
self.assertEqual(validate_download(payload)["workFolder"], str(Path(folder).resolve()))
with self.assertRaisesRegex(ValueError, "Рабочая папка"):
validate_download({**payload, "workFolder": str(Path(folder) / "missing")})
@unittest.skipUnless(os.name == "nt", "Native worker process and disk cleanup")
def test_native_worker_cancel_removes_both_workspaces_and_stops_ffmpeg(self):
runtime = ROOT / ".runtime"
runtime.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(dir=runtime) as work, tempfile.TemporaryDirectory() as target:
fixture = Path(work) / "host"
fixture.mkdir()
(fixture / "worker.py").write_text('''import json,subprocess,sys,tempfile,time
from pathlib import Path
task=json.loads(sys.stdin.readline())['payload']
for folder in [task['workFolder'],task['folder']]:
scratch=Path(tempfile.mkdtemp(prefix='.ytdl-',dir=folder))
(scratch/'partial.mp4').write_bytes(b'partial')
print(json.dumps({'type':'workspace','path':str(scratch)}),flush=True)
child=subprocess.Popen([task['ffmpeg'],'-nostdin','-v','error','-re','-f','lavfi','-i','testsrc2=size=64x64:rate=10','-f','null','-'],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print(json.dumps({'type':'progress','stage':'merging','percent':25,'testPid':child.pid}),flush=True)
time.sleep(60)
''', encoding="utf-8")
for folder in [work, target]: (Path(folder) / "keep.txt").write_text("user file")
with patch("host.main.ROOT", Path(work)):
host = NativeHost(io.BytesIO())
host._capabilities = {}
host.jobs["native"] = {"id": "native", "status": "running", "stage": "preparing"}
thread = threading.Thread(target=host.run_job, args=("native", {"folder": target, "workFolder": work, "ffmpeg": tool("ffmpeg")}))
thread.start()
try:
deadline = time.monotonic() + 10
while "testPid" not in host.jobs["native"] and time.monotonic() < deadline: time.sleep(0.025)
pid = host.jobs["native"]["testPid"]
host.handle({"id": "stop", "action": "cancel", "payload": {"jobId": "native"}})
thread.join(timeout=10)
self.assertFalse(thread.is_alive())
self.assertFalse(self.running(pid))
self.assertFalse(host.processes)
for folder in [work, target]:
self.assertFalse(list(Path(folder).glob(".ytdl-*")))
self.assertEqual((Path(folder) / "keep.txt").read_text(), "user file")
finally:
host.close()
thread.join(timeout=10)
@unittest.skipUnless(os.name == "nt", "Actual Windows process-tree regression")
def test_cancel_kills_ffmpeg_after_parent_exit_and_keeps_other_job(self):
launcher = """import json,subprocess,sys,time
task=json.loads(sys.stdin.readline())
child=subprocess.Popen([task['ffmpeg'],'-hide_banner','-loglevel','error','-nostdin','-re','-f','lavfi','-i','testsrc2=size=64x64:rate=10','-f','null','-'],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print(child.pid,flush=True)
if not task['exit']:time.sleep(60)
"""
processes = []
host = NativeHost(io.BytesIO())
try:
children = []
for exits in (True, False):
p = ProcessTree.launch([sys.executable, "-c", launcher], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, creationflags=NO_WINDOW)
processes.append(p)
p.stdin.write(json.dumps({"ffmpeg": tool("ffmpeg"), "exit": exits}) + "\n")
p.stdin.close()
children.append(int(p.stdout.readline()))
if exits: p.wait(timeout=10)
host.jobs["cancel"] = {"id": "cancel", "status": "running", "stage": "merging"}
host.processes["cancel"] = processes[0]
self.assertTrue(self.running(children[0]))
self.assertTrue(self.running(children[1]))
host.handle({"id": "request", "action": "cancel", "payload": {"jobId": "cancel"}})
self.assertEqual(host.jobs["cancel"]["status"], "cancelled")
self.assertFalse(self.running(children[0]), "Cancelled FFmpeg must actually exit")
self.assertTrue(self.running(children[1]), "A different job's FFmpeg must survive")
finally:
host.close()
for p in processes:
tree = getattr(p, "ytdl_process_tree", None)
if tree:
tree.terminate(); tree.close()
elif p.poll() is None:
p.kill(); p.wait()
p.stdout.close()
for pid in locals().get("children", []):
if self.running(pid):
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=NO_WINDOW, timeout=10)
@staticmethod
def running(pid):
import ctypes
from ctypes import wintypes
api = ctypes.WinDLL("kernel32", use_last_error=True)
api.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
api.OpenProcess.restype = wintypes.HANDLE
api.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
api.WaitForSingleObject.restype = wintypes.DWORD
api.CloseHandle.argtypes = [wintypes.HANDLE]
handle = api.OpenProcess(0x100000, False, pid)
if not handle: return False
try: return api.WaitForSingleObject(handle, 0) == 258
finally: api.CloseHandle(handle)
def test_actual_download_merger_reports_progress_and_publishes_across_disks(self):
from yt_dlp import YoutubeDL
runtime = ROOT / ".runtime"
runtime.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="processing-test-", dir=runtime) as working, tempfile.TemporaryDirectory() as output:
work, destination = Path(working), Path(output)
video, audio = work / "video.mp4", work / "audio.m4a"
for inputs, target, codec in [(["-f", "lavfi", "-i", "testsrc2=size=160x90:rate=24"], video, ["-an", "-c:v", "libx264", "-preset", "ultrafast"]),
(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000"], audio, ["-vn", "-c:a", "aac"])]:
subprocess.run([tool("ffmpeg"), "-nostdin", "-v", "error", "-y", *inputs, "-t", "12", *codec, str(target)],
creationflags=NO_WINDOW, stdin=subprocess.DEVNULL, check=True, timeout=30)
class Handler(SimpleHTTPRequestHandler):
def log_message(self, *args): pass
server = ThreadingHTTPServer(("127.0.0.1", 0), functools.partial(Handler, directory=str(work)))
threading.Thread(target=server.serve_forever, daemon=True).start()
events = []
origin = f"http://127.0.0.1:{server.server_port}/"
def extract(ydl, url, download=True):
return ydl.process_ie_result({"id": "jNQXAC9IVRw", "title": "Processing regression", "duration": 12,
"webpage_url": "https://youtu.be/jNQXAC9IVRw", "extractor": "fixture", "extractor_key": "Fixture", "formats": [
{"format_id": "137", "url": origin + "video.mp4", "ext": "mp4", "protocol": "http", "vcodec": "avc1", "acodec": "none", "height": 90, "width": 160, "fps": 24},
{"format_id": "140", "url": origin + "audio.m4a", "ext": "m4a", "protocol": "http", "vcodec": "none", "acodec": "mp4a.40.2", "abr": 128}]}, download=download)
try:
with patch.object(YoutubeDL, "extract_info", extract), patch("host.worker.emit", side_effect=lambda kind, **data: events.append({"type": kind, **data})):
result = globals()["download"]({"url": "https://youtu.be/jNQXAC9IVRw", "folder": str(destination),
"workFolder": str(work), "mode": "video", "container": "mp4", "codec": "copy"}, [])
finally:
server.shutdown(); server.server_close()
saved = Path(result["path"])
self.assertEqual(saved.parent, destination.resolve())
self.assertEqual({s["codec_type"] for s in probe(saved)["streams"]}, {"video", "audio"})
self.assertTrue(any(e.get("stage") == "merging" and e.get("processedSeconds", 0) > 0 and e.get("written", 0) > 0 and e.get("percent") is not None for e in events), events)
self.assertTrue(any(e.get("stage") == "remuxing" and e.get("engine") == "copy" for e in events), events)
stages = [e.get("stage") for e in events if e.get("type") == "progress"]
self.assertLess(stages.index("merging"), stages.index("remuxing"))
self.assertNotIn("merging", stages[stages.index("remuxing"):])
if work.stat().st_dev != destination.stat().st_dev:
self.assertTrue(any(e.get("stage") == "saving" and e.get("copied", 0) > 0 for e in events), events)
self.assertFalse(list(work.glob(".ytdl-*")))
self.assertFalse(list(destination.glob(".ytdl-*")))
self.assertEqual(result["size"], saved.stat().st_size)
if __name__ == "__main__": unittest.main()
+64
View File
@@ -0,0 +1,64 @@
import hashlib
import io
import tempfile
import unittest
import zipfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from host.main import NativeHost
from host.updates import extract_zip, fetch, python_release, trusted_url
class UpdateTests(unittest.TestCase):
def test_automatic_checks_wait_for_idle_and_respect_disabled_setting(self):
with tempfile.TemporaryDirectory() as temporary, patch("host.main.ROOT", Path(temporary)):
host = NativeHost(io.BytesIO())
try:
with patch("host.main.installation_root", return_value=Path(temporary)), patch.object(host, "schedule_updates"), \
patch("host.updates.settings", return_value={"enabled": True}) as settings, patch("host.updates.due", return_value=True), \
patch("host.updates.check_updates", return_value=[]) as check:
host.jobs["video"] = {"status": "running"}
host.auto_check(); check.assert_not_called()
host.jobs.clear(); host.auto_check(); check.assert_called_once()
settings.return_value = {"enabled": False}; check.reset_mock()
host.auto_check(); check.assert_not_called()
finally: host.close()
def test_vendor_urls_and_archive_paths_are_restricted(self):
trusted_url("https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip")
for url in ("http://nodejs.org/file", "https://nodejs.org.evil.test/file", "https://name:secret@nodejs.org/file"):
with self.assertRaises(ValueError): trusted_url(url)
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for name in ("../outside.exe", "C:/outside.exe", "runtime/../../outside.exe"):
archive = root / "bad.zip"
with zipfile.ZipFile(archive, "w") as package: package.writestr(name, b"bad")
with self.assertRaises(ValueError): extract_zip(archive, root / "component")
self.assertFalse((root / "outside.exe").exists())
def test_modified_download_is_rejected_before_use(self):
class Response:
headers = {"Content-Length": "8"}
def __enter__(self): return self
def __exit__(self, *args): return False
def iter_content(self, count): yield b"modified"
with tempfile.TemporaryDirectory() as temporary, patch("host.updates.get", return_value=Response()):
item = {"url": "https://nodejs.org/file", "sha256": hashlib.sha256(b"original").hexdigest()}
with self.assertRaisesRegex(ValueError, "Контрольная сумма"):
fetch(item, Path(temporary) / "download.zip", lambda _: None)
def test_python_uses_official_hash_and_stays_in_compatible_branch(self):
checksum = "a" * 64
index = '<a href="3.14.0/">3.14</a><a href="3.13.16/">16</a><a href="3.13.15/">15</a><a href="3.13.14/">14</a>'
def response(url):
if url.endswith("/ftp/python/"): return SimpleNamespace(text=index)
if "31316" in url: return SimpleNamespace(text="<tr><td>Source only</td></tr>")
return SimpleNamespace(text=f'<tr><td><a href="python-3.13.15-embed-amd64.zip">Windows embed</a></td><td><code>{checksum}</code></td></tr>')
with patch("host.updates.sys.version_info", SimpleNamespace(major=3, minor=13)), patch("host.updates.sys.version", "3.13.14"), patch("host.updates.get", side_effect=response):
result = python_release()
self.assertEqual(result["version"], "3.13.15"); self.assertEqual(result["sha256"], checksum)
if __name__ == "__main__": unittest.main()
+139
View File
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
await fs.mkdir(artifacts, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
const context = await browser.newContext({ viewport: { width: 1200, height: 800 }, reducedMotion: "reduce" });
await context.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
const poster = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="330" height="186"><defs><linearGradient id="g"><stop stop-color="#096574"/><stop offset="1" stop-color="#192636"/></linearGradient></defs><path fill="url(#g)" d="M0 0h330v186H0z"/></svg>');
await context.route("https://www.youtube.com/**", route => route.fulfill({ contentType: "text/html", body: `<!doctype html><html dark><head><meta charset="utf-8"><style>
body{margin:24px;background:#0f0f0f;color:#eee;font:16px/1.4 Arial}main{display:flex;gap:26px;align-items:flex-start}.card{display:block;width:330px;position:relative}a{color:inherit;text-decoration:none}img,video{display:block;width:100%;height:auto;aspect-ratio:330/186;object-fit:cover;border-radius:12px}a.title{display:block;font-weight:600;min-height:48px;margin-top:10px}.views{color:#aaa;margin-top:5px}ytd-thumbnail,yt-thumbnail-view-model{display:block}.short-image{display:block}.legacy-image{display:block;padding-bottom:28px}.modern-image{display:block;padding-bottom:20px}.scaled{transform:scale(.85);transform-origin:top left}.bordered{border:3px solid #333;padding:7px;box-sizing:border-box}
</style></head><body><main>
<ytm-shorts-lockup-view-model id="short-card" class="card"><a class="short-image" href="/shorts/Mf9GCn_LsUI"><yt-thumbnail-view-model><img src="${poster}" alt="Shorts preview"></yt-thumbnail-view-model></a><a class="title" href="/shorts/Mf9GCn_LsUI">ТЫ НЕ ВСТРЕЧАЛ этого Левиафана в Subnautica 2...</a><div class="views">1,6 млн просмотров</div></ytm-shorts-lockup-view-model>
<div id="legacy-card" class="card"><ytd-thumbnail><a id="thumbnail" class="legacy-image" href="/watch?v=jNQXAC9IVRw"><img src="${poster}" alt="Video preview"></a></ytd-thumbnail><div class="title">S.T.A.L.K.E.R. — видео и описание</div></div>
<yt-lockup-view-model id="modern-card" class="card scaled"><a class="yt-lockup-view-model__content-image modern-image" href="/watch?v=aircAruvnKk"><yt-thumbnail-view-model class="bordered"><img src="${poster}" alt="Modern preview"></yt-thumbnail-view-model><span>Название внутри ссылки</span></a><div class="views">Информация под превью</div></yt-lockup-view-model>
</main></body></html>` }));
const page = await context.newPage();
const errors = []; page.on("pageerror", error => errors.push(error.message));
async function assertInside(id) {
const geometry = await page.locator(`#${id}`).evaluate(card => {
const owner = card.dataset.ytdlPreview || card.querySelector('[data-ytdl-preview]')?.dataset.ytdlPreview;
const host = document.querySelector(`[data-ytdl-preview-for="${owner}"]`);
const media = card.querySelector('video, img');
const box = value => ({ left:value.left, top:value.top, right:value.right, bottom:value.bottom });
return { media: box(media.getBoundingClientRect()), buttons: [...host.shadowRoot.querySelectorAll('button')].map(button => box(button.getBoundingClientRect())), count: document.querySelectorAll(`[data-ytdl-preview-for="${owner}"]`).length };
});
assert.equal(geometry.count, 1, `Only one control pair in ${id}`);
for (const button of geometry.buttons) {
assert.ok(button.left >= geometry.media.left + 3 && button.right <= geometry.media.right - 3 && button.top >= geometry.media.top + 3 && button.bottom <= geometry.media.bottom - 3,
`Controls must stay inside ${id}'s actual picture: ${JSON.stringify(geometry)}`);
}
}
try {
await page.goto("https://www.youtube.com/");
await page.addScriptTag({ path: path.join(root, "extension/controls.js") });
for (const id of ["short-card", "legacy-card", "modern-card"]) {
await page.locator(`#${id} [data-ytdl-preview], #${id}[data-ytdl-preview]`).waitFor(); await assertInside(id);
}
// The real YouTube app is absolutely positioned: BODY has zero height, and
// its scroll/auto overflow is propagated to the viewport, not a zero-size clip.
await page.evaluate(() => {
document.querySelector('main').style.position='absolute';
document.body.style.overflowY='scroll'; document.body.style.overflowX='auto';
});
await page.waitForTimeout(100);
const shortOwner=await page.locator('#short-card').getAttribute('data-ytdl-preview');
assert.equal(await page.locator(`[data-ytdl-preview-for="${shortOwner}"]`).isVisible(),true,'Viewport overflow must not hide buttons when BODY has zero height');
await assertInside('short-card');
await page.screenshot({ path: path.join(artifacts, "thumbnail-placement.png"), animations: "disabled" });
await page.evaluate(() => {
const card = document.querySelector('#short-card');
card.addEventListener('mouseenter', () => {
const replacement = document.createElement('a'); replacement.href = '/shorts/Mf9GCn_LsUI'; replacement.className = 'short-image';
replacement.innerHTML = '<video style="background:#123b47"></video>';
card.querySelector('.short-image').replaceWith(replacement);
const player = document.createElement('div'); player.className = 'hover-player';
player.style.cssText = 'position:absolute;inset:0 0 auto;height:186px;background:#123b47;z-index:30;pointer-events:auto;border-radius:12px'; card.append(player);
}, { once:true });
});
await page.locator('#short-card').hover();
await page.locator('#short-card .hover-player').waitFor();
await page.waitForTimeout(250);
await assertInside('short-card');
const shortKey = await page.locator('#short-card').getAttribute('data-ytdl-preview');
const shortButtons = page.locator(`[data-ytdl-preview-for="${shortKey}"]`);
await shortButtons.locator('.download').click();
assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), 'https://www.youtube.com/watch?v=Mf9GCn_LsUI');
assert.equal(page.url(), 'https://www.youtube.com/');
await page.screenshot({ path: path.join(artifacts, 'thumbnail-hover-placement.png'), animations:'disabled' });
// YouTube can hide the original card while rendering its hover player in a
// separate overlay. Controls must be outside that opacity/visibility subtree.
await page.evaluate(() => document.querySelector('#short-card').style.opacity = '0');
assert.equal(await shortButtons.evaluate(host => host.parentElement === document.documentElement), true);
assert.equal(await shortButtons.locator('.download').evaluate(button => Number(getComputedStyle(button).opacity)), 1);
await shortButtons.locator('.download').click();
await page.evaluate(() => {
document.querySelector('#short-card').style.opacity = '';
document.querySelector('#short-card .short-image').style.visibility = 'hidden';
});
await page.waitForTimeout(250);
assert.equal(await shortButtons.isVisible(), true);
await shortButtons.locator('.download').click();
await page.evaluate(() => document.querySelector('#short-card .short-image').style.visibility = '');
// Hover playback can remove the entire image link, leaving only metadata and
// a separate player. That metadata must not move the controls into the title.
const beforeRemoval = await shortButtons.boundingBox();
await page.evaluate(() => {
const link = document.querySelector('#short-card .short-image');
window.__removedThumbnail = link; link.remove();
});
await page.waitForTimeout(250);
assert.deepEqual(await shortButtons.boundingBox(), beforeRemoval);
await shortButtons.locator('.download').click();
await page.evaluate(() => document.querySelector('#short-card').prepend(window.__removedThumbnail));
// Layout changes without a window resize: columns narrower, title grows,
// and a lazy image gets its final height after initial attachment.
await page.evaluate(() => {
for (const card of document.querySelectorAll('.card')) card.style.width = '260px';
document.querySelector('#short-card .title').style.minHeight = '110px';
document.querySelector('#short-card .hover-player').style.height = '146.5px';
document.querySelector('#legacy-card img').style.aspectRatio = '4/3';
});
await page.waitForTimeout(300);
for (const id of ['short-card','legacy-card','modern-card']) await assertInside(id);
await page.evaluate(() => {
document.querySelector('#short-card .short-image').href = '/shorts/jNQXAC9IVRw';
document.querySelector('#short-card .title').href = '/shorts/jNQXAC9IVRw';
});
await shortButtons.locator('.download').click();
assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), 'https://www.youtube.com/watch?v=jNQXAC9IVRw');
await page.evaluate(() => { document.body.style.minHeight = '1500px'; });
await page.setViewportSize({width:1200,height:300});
await page.evaluate(() => window.scrollTo(0,60));
await page.waitForTimeout(100);
await assertInside('short-card');
// A horizontally clipped Shorts shelf must not leave buttons floating over
// content outside the visible shelf.
await page.evaluate(() => {
const card = document.querySelector('#short-card');
const shelf = document.createElement('div'); shelf.id = 'short-shelf';
shelf.style.cssText = 'width:260px;overflow:hidden;flex-shrink:0';
card.before(shelf); shelf.append(card); card.style.marginLeft = '300px';
});
await page.waitForTimeout(100);
assert.equal(await shortButtons.isVisible(),false);
await page.evaluate(() => document.querySelector('#short-card').style.marginLeft='0');
await page.waitForTimeout(100);
assert.equal(await shortButtons.isVisible(),true);
await page.evaluate(() => document.querySelector('#short-card').remove());
await shortButtons.waitFor({state:'detached'});
assert.deepEqual(errors, []);
console.log('Thumbnail placement passed: real YouTube zero-height BODY, image bounds, title links, padding, borders, scaled layout, replaced/hidden/removed hover previews, responsive/lazy sizing, scroll, clipped shelves and recycled URLs.');
} finally { await browser.close(); }
+73
View File
@@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createUiConnection } from "../extension/ui-connection.js";
function harness() {
const ports = [], events = [];
const runtime = { connect() {
const messages = [], disconnects = [];
const port = { sent: [], dead: false,
onMessage: { addListener: listener => messages.push(listener) },
onDisconnect: { addListener: listener => disconnects.push(listener) },
postMessage(message) {
if (this.dead) throw new Error("Attempting to use a disconnected port object");
this.sent.push(message);
},
respond(result, index = this.sent.length - 1) { messages.forEach(listener => listener({ id: this.sent[index].id, ok: true, result })); },
emit(message) { messages.forEach(listener => listener(message)); },
disconnect() { this.dead = true; disconnects.forEach(listener => listener()); },
};
ports.push(port); return port;
} };
const connection = createUiConnection({ runtime, onEvent: event => events.push(event), onDisconnect: () => events.push({ disconnected: true }) });
return { ...connection, ports, events, runtime };
}
test("a menu reconnects after worker shutdown, without resending an in-flight operation", async () => {
const h = harness();
const pending = h.call("helper_remove");
const rejected = assert.rejects(pending, /Связь с расширением прервана/);
const old = h.ports[0]; old.disconnect(); await rejected;
const next = h.call("helper_start");
assert.equal(h.ports.length, 2); assert.equal(old.sent.length, 1);
assert.equal(h.ports[1].sent[0].action, "helper_start");
h.ports[1].respond({ ready: true }); assert.deepEqual(await next, { ready: true });
// Late events from the old worker must not change the new status.
const count = h.events.length;
old.emit({ event: "helper", helper: { status: "missing" } }); old.disconnect();
assert.equal(h.events.length, count);
h.ports[1].emit({ event: "helper", helper: { status: "running" } });
assert.equal(h.events.at(-1).helper.status, "running");
});
test("a disconnected-port exception before its callback reconnects in the same click", async () => {
const h = harness();
const initial = h.call("hello"); h.ports[0].respond({ ready: true }); await initial;
h.ports[0].dead = true;
const retry = h.call("helper_start");
assert.equal(h.ports.length, 2);
h.ports[0].disconnect();
h.ports[1].respond({ ready: true }); assert.equal((await retry).ready, true);
assert.equal(h.ports[0].sent.length, 1, "Failed post was never delivered");
});
test("repeated disconnected-port failures stop after one retry with a readable error", async () => {
let attempts = 0;
const h = createUiConnection({ runtime: { connect() {
attempts++;
return { onMessage: { addListener() {} }, onDisconnect: { addListener() {} },
postMessage() { throw new Error("Attempting to use a disconnected port object"); } };
} } });
await assert.rejects(h.call("helper_start"), /Проверить подключение/);
assert.equal(attempts, 2);
});
test("helper errors are delivered once and do not replay updates or removal", async () => {
for (const action of ["helper_update", "helper_remove", "download"]) {
const h = harness(), result = h.call(action);
const rejected = assert.rejects(result, /Дождитесь завершения/);
h.ports[0].emit({ id: h.ports[0].sent[0].id, ok: false, error: "Дождитесь завершения загрузок" });
await rejected;
assert.equal(h.ports.length, 1); assert.equal(h.ports[0].sent.length, 1);
}
});
+100
View File
@@ -0,0 +1,100 @@
// Deterministic browser APIs for layout and interaction tests, without touching browser accounts.
(() => {
const listeners = [];
const contentListeners = [];
const disconnectListeners = [];
let sessionEnabled = false;
const fixtureStorage = { preferences: {}, onboarding: { state: "complete", version: 1 } };
window.__fixtureStorage = fixtureStorage;
window.__downloadRequests = [];
window.__quickRequests = [];
window.__emitJob = job => {
fixtureStorage.jobs ||= {};
fixtureStorage.jobs[job.id] = job;
listeners.forEach(listener => listener({ event: "job", job: fixtureStorage.jobs[job.id] }));
};
window.__historyRequests = [];
window.__uiDisconnect = () => disconnectListeners.forEach(listener => listener());
window.__contentMessage = message => contentListeners.forEach(listener => listener(message));
let autoUpdate = true;
window.__updateRequests = [];
const hardware = () => ({ version: window.__helperVersion || "1.3.2", bundle: { mode: "autonomous", version: window.__helperVersion || "1.3.2", updateTogether: true }, supportsComponentUpdates: true, supportsWorkFolder: true, updateSettings: { enabled: autoUpdate },
gpu: window.__amdOnly ? "AMD Radeon RX 7900 XTX" : "NVIDIA GeForce RTX 5080", nvenc: window.__dependencyMissing || window.__amdOnly ? [] : ["h264", "hevc", "av1"],
amf: window.__amdOnly ? ["h264", "hevc", "av1"] : [], amf10bit: window.__amdOnly ? ["hevc", "av1"] : [], defaultFolder: "D:\\Videos", ready: !window.__dependencyMissing,
dependencies: ["python", "yt_dlp", "ejs", "node", "ffmpeg"].map(id => ({ id, status: window.__dependencyMissing && id === "ffmpeg" ? "missing" : "installed", version: "test-version" })) });
const metadata = { id: "aircAruvnKk", url: "https://www.youtube.com/watch?v=aircAruvnKk",
title: "But what is a neural network? | Deep learning chapter 1", channel: "3Blue1Brown", duration: 1148,
videos: [
{ id: "401", height: 2160, fps: 60, codec: "av01.0.13M.08", extension: "mp4", hasAudio: false, size: 350000000, hdr: "SDR" },
{ id: "137", height: 1080, fps: 30, codec: "avc1.640028", extension: "mp4", hasAudio: false, size: 100000000, hdr: "SDR" },
{ id: "18", height: 360, fps: 30, codec: "avc1.42001E", extension: "mp4", hasAudio: true, size: 35000000, hdr: "SDR" },
], audios: [{ id: "251", codec: "opus", extension: "webm", bitrate: 160, language: "en", note: "English", size: 5000000 }] };
window.chrome = {
runtime: {
getManifest: () => ({ version: window.__extensionVersion || "1.4.5" }),
getURL: file => `https://ui.ytdl.test/${file}`,
sendMessage: async message => {
if (message.type === "QUICK_DOWNLOAD") { window.__quickRequests.push(message.url); return { ok: true, jobId: "fixture-job" }; }
if (message.type === "CLOSE_MENU") parent.postMessage({ type: "FIXTURE_CLOSE_MENU" }, "https://www.youtube.com");
return { tabId: 42, jobs: [] };
},
onMessage: { addListener: listener => contentListeners.push(listener) },
connect: () => ({
onMessage: { addListener: listener => listeners.push(listener) },
onDisconnect: { addListener: listener => disconnectListeners.push(listener) },
postMessage: message => {
let result;
if ((message.action === "hello" || message.action === "helper_start") && window.__helperMissing) {
setTimeout(() => {
listeners.forEach(listener => listener({ event: "helper", helper: { status: "missing", message: "Помощник не установлен. Скачайте установщик." } }));
listeners.forEach(listener => listener({ id: message.id, ok: false, error: "Помощник не установлен." }));
}, 5);
return;
}
if (message.action === "hello" || message.action === "helper_start") result = hardware();
if (message.action === "helper_status") result = { status: "running", hardware: hardware() };
if (message.action === "update_settings") { autoUpdate = message.payload.enabled; result = { enabled: autoUpdate }; }
if (message.action === "helper_update") {
window.__updateRequests.push(message.payload); result = { jobId: "fixture-update" };
setTimeout(() => window.__emitJob({ id: "fixture-update", kind: "update", canCancel: false, title: "Обновление компонентов", status: "running", stage: "updating", percent: 36, message: "Скачиваю FFmpeg…" }), 10);
}
if (message.action === "dependency_install") {
result = { jobId: "fixture-dependency" };
setTimeout(() => {
window.__dependencyMissing = false;
listeners.forEach(listener => listener({ event: "helper", helper: { status: "running", hardware: hardware() } }));
}, 30);
}
if (message.action === "helper_remove") {
result = { removed: true };
listeners.forEach(listener => listener({ event: "helper", helper: { status: "missing", message: "Помощник удалён." } }));
}
if (message.action === "session_status") result = { status: sessionEnabled ? "connected" : "disabled", message: "Вход текущего браузера" };
if (message.action === "session_enable") { sessionEnabled = message.payload.enabled; result = { status: sessionEnabled ? "connected" : "disabled" }; }
if (message.action === "inspect") {
if (window.__requiresLogin && !sessionEnabled) {
setTimeout(() => listeners.forEach(listener => listener({id:message.id,ok:false,error:"ERROR: Sign in to confirm you’re not a bot. Use --cookies for authentication"})),5);
return;
}
result = metadata;
}
if (message.action === "pick_folder") result = { folder: "D:\\Videos\\YouTube" };
if (message.action === "open_history") { window.__historyRequests.push(message.payload); result = { opened: true }; }
if (message.action === "download") { window.__downloadRequests.push(message.payload); result = { jobId: "fixture-job" }; }
if (message.action === "state" || message.action === "jobs") result = { jobs: Object.values(fixtureStorage.jobs || {}) };
setTimeout(() => listeners.forEach(listener => listener({ id: message.id, ok: true, result })), 5);
},
}),
},
storage: { local: {
get: async keys => {
const data = { ...fixtureStorage };
if (window.__firstRun && !window.__onboardingStored) delete data.onboarding;
return Object.fromEntries((Array.isArray(keys) ? keys : [keys]).filter(key => key in data).map(key => [key, structuredClone(data[key])]));
},
set: async data => { Object.assign(fixtureStorage, structuredClone(data)); if (data.onboarding) window.__onboardingStored = true; },
} },
permissions: { request: async () => window.__denySessionPermission !== true },
};
window.__helperOffline = () => listeners.forEach(listener => listener({ event: "helper", helper: { status: "offline", message: "Помощник отключён." } }));
})();
+239
View File
@@ -0,0 +1,239 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
await fs.mkdir(artifacts, { recursive: true });
const browser = await chromium.launch({ headless: true,
executablePath: process.env.YTDL_TEST_BROWSER || "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
const context = await browser.newContext({ viewport: { width: 1180, height: 860 }, reducedMotion: "reduce" });
await context.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
await context.route("**/*", async route => {
const url = new URL(route.request().url());
if (url.hostname === "ui.ytdl.test") {
const name = url.pathname.slice(1).split("/").pop();
if (!["downloader.html", "styles.css", "downloader.js", "onboarding.js", "ui-connection.js", "theme-init.js", "shared.js", "session.js"].includes(name)) return route.abort();
const type = name.endsWith(".html") ? "text/html" : name.endsWith(".css") ? "text/css" : "application/javascript";
return route.fulfill({ body: await fs.readFile(path.join(root, "extension", name)), contentType: type });
}
if (url.hostname === "www.youtube.com") return route.fulfill({ contentType: "text/html", body: `<!doctype html><html dark><head><meta charset="utf-8"><style>body{margin:0;background:#0f0f0f;color:#eee;font:14px "Segoe UI",sans-serif}main{width:780px;margin:35px}.player{height:330px;background:linear-gradient(135deg,#162c43,#0a1421);border-radius:15px;display:grid;place-items:center;color:#65849c;font-size:27px}h1{font-size:22px;margin:22px 0}#owner{display:flex;align-items:center;gap:9px}.channel{margin-right:12px}.channel b{display:block}.channel small{color:#999}#subscribe-button button{border:0;border-radius:22px;background:#eee;padding:11px 17px;color:#111;font-weight:600}.note{color:#8d98a6;margin-top:35px}.avatar{width:38px;height:38px;background:#577295;border-radius:50%}</style></head><body><main><div class="player">Deep learning · chapter 1</div><h1>But what is a neural network?</h1><ytd-watch-metadata><div id="owner"><span class="avatar"></span><span class="channel"><b>3Blue1Brown</b><small>Наглядная математика</small></span><div id="subscribe-button"><button>Подписаться</button></div></div></ytd-watch-metadata><p class="note">Тест интерфейса YouTubeDL</p></main></body></html>` });
return route.abort();
});
const page = await context.newPage();
const errors = [];
page.on("pageerror", error => errors.push(error.message));
try {
await page.goto("https://www.youtube.com/watch?v=aircAruvnKk");
await page.addScriptTag({ path: path.join(root, "extension/controls.js") });
await page.addScriptTag({ path: path.join(root, "extension/content.js") });
await page.evaluate(() => window.addEventListener("message", event => {
if (event.origin === "https://ui.ytdl.test" && event.data?.type === "FIXTURE_CLOSE_MENU") window.__contentMessage({ type: "CLOSE_MENU" });
}));
assert.equal(await page.locator("#subscribe-button + #ytdl-subscribe-download").count(), 1, "Button must be next to Subscribe");
const siteDownload = page.locator("#ytdl-subscribe-download .download");
assert.equal(await siteDownload.locator("svg").count(), 1, "Keep the site download icon");
const beforeHover = await siteDownload.boundingBox();
await siteDownload.hover();
assert.deepEqual(await siteDownload.boundingBox(), beforeHover, "The site button must not move on hover");
await siteDownload.click();
assert.equal(await page.locator("#ytdl-menu").count(), 0, "Download starts directly without a choice menu");
assert.deepEqual(await page.evaluate(() => window.__quickRequests), ["https://www.youtube.com/watch?v=aircAruvnKk"]);
await page.locator("#ytdl-subscribe-download .settings").click();
const frame = page.frameLocator("#ytdl-menu iframe");
await frame.locator("#download-form").waitFor({ state: "visible" });
assert.equal(await frame.locator("#download svg").count(), 0);
assert.ok(!(await frame.locator("#download").textContent()).includes("↓"));
assert.equal(await frame.locator("#helper-status").textContent(), "Помощник работает");
assert.equal(await frame.locator('.dependency-row[data-status="installed"]').count(), 5);
assert.equal(await frame.locator("#quality option").count(), 4);
await frame.locator("#quality").selectOption("137");
const actionSizes = await frame.locator('.form-actions > button').evaluateAll(buttons => buttons.map(button => ({ width: button.getBoundingClientRect().width, height: button.getBoundingClientRect().height, font: getComputedStyle(button).fontSize })));
assert.ok(Math.abs(actionSizes[0].width - actionSizes[1].width) < 1);
assert.equal(actionSizes[0].height, actionSizes[1].height); assert.equal(actionSizes[0].font, actionSizes[1].font);
await frame.locator('.form-actions').screenshot({path:path.join(artifacts,'form-actions.png')});
await frame.locator('body').evaluate(() => window.__emitJob({ id: 'old-download', title: 'Сохранённый ролик', status: 'complete', stage: 'complete', result: {path:'D:/Videos/old.mp4'}, createdAt: 1 }));
assert.equal(await frame.locator('[data-job-id="old-download"]').count(), 0, 'Completed history moves to its own page');
await frame.locator('#open-history').click();
await frame.locator('body').evaluate(() => {
if (window.__historyRequests.length !== 1) throw new Error('History button must open a new tab');
});
await frame.locator('body').evaluate(() => window.__emitJob({id:'active-download',title:'Текущая загрузка',status:'running',stage:'downloading',percent:12,createdAt:2}));
assert.equal(await frame.locator('#active-jobs .job-card').isVisible(), true);
await frame.locator('body').evaluate(() => window.__emitJob({id:'active-download',title:'Текущая загрузка',status:'complete',stage:'complete',result:{path:'D:/Videos/new.mp4'},createdAt:2}));
assert.equal(await frame.locator('#active-jobs-section').isVisible(), false);
assert.equal(await frame.locator('#open-history').isVisible(), true);
await frame.locator("html[data-theme=dark]").waitFor();
await frame.locator("body").evaluate(() => { for (const animation of document.getAnimations()) if (Number.isFinite(animation.effect.getTiming().iterations)) animation.finish(); });
await page.screenshot({ path: path.join(artifacts, "menu-dark.png"), animations: "disabled" });
await frame.locator(".mode-switch label").filter({ hasText: /^Аудио$/ }).click();
assert.equal(await frame.locator("#video-options").isVisible(), false);
assert.equal(await frame.locator("#container option").count(), 5);
await frame.locator("#container").selectOption("mp3");
await frame.locator("#pick-folder").click();
await frame.locator("#pick-folder:not([disabled])").waitFor();
assert.equal(await frame.locator("#folder").inputValue(), "D:\\Videos\\YouTube");
await frame.locator("#download").click();
await page.locator("#ytdl-menu").waitFor({ state: "detached" });
const job = { id: "fixture-job", title: "But what is a neural network?", status: "running", stage: "encoding", engine: "nvenc", percent: 64, speed: "12.8x" };
await page.evaluate(job => window.__contentMessage({ type: "JOB_PROGRESS", job }), job);
await page.locator("#ytdl-notifications .toast").waitFor();
assert.match(await page.locator("#ytdl-notifications .stage").textContent(), /NVENC/);
await page.screenshot({ path: path.join(artifacts, "progress-toast.png"), animations: "disabled" });
await page.evaluate(() => { document.documentElement.removeAttribute("dark"); document.body.style.background = "#f9fafc"; document.body.style.color = "#182234"; });
await page.locator("#ytdl-notifications:not([dark])").waitFor();
await page.locator("#ytdl-subscribe-download .settings").click();
const lightFrame = page.frameLocator("#ytdl-menu iframe");
await lightFrame.locator("#download-form").waitFor({ state: "visible" });
assert.equal(await lightFrame.locator("html").getAttribute("data-theme"), "light");
await page.screenshot({ path: path.join(artifacts, "menu-light.png"), animations: "disabled" });
await page.setViewportSize({ width: 340, height: 720 });
const box = await page.locator("#ytdl-menu").boundingBox();
assert.ok(box.x >= 0 && box.x + box.width <= 340, "Menu must stay inside a narrow viewport");
await lightFrame.locator("#download-form").waitFor({ state: "visible" });
const overflow = await lightFrame.locator("html").evaluate(node => node.scrollWidth > node.clientWidth);
assert.equal(overflow, false, "No horizontal overflow");
await lightFrame.locator("html").evaluate(() => window.__helperOffline());
assert.equal(await lightFrame.locator("#helper-status").textContent(), "Помощник отключён");
assert.equal(await lightFrame.locator('.dependency-row[data-status="unknown"]').count(), 5);
await lightFrame.locator("#helper-start").click();
await lightFrame.locator("#download-form").waitFor({ state: "visible" });
assert.equal(await lightFrame.locator("#helper-status").textContent(), "Помощник работает");
await lightFrame.locator("#close").click();
await page.locator("#ytdl-menu").waitFor({ state: "detached" });
await page.evaluate(() => {
const thumbnail = document.createElement("ytd-thumbnail");
thumbnail.innerHTML = '<a id="thumbnail" href="/watch?v=jNQXAC9IVRw" style="display:block;width:260px;height:146px;background:#30506b"><img alt="Test video" style="width:100%;height:100%"></a>';
document.querySelector("main").prepend(thumbnail);
});
await page.locator(".ytdl-thumbnail-download .download").click();
assert.equal(page.url(), "https://www.youtube.com/watch?v=aircAruvnKk", "A thumbnail download must not navigate");
assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), "https://www.youtube.com/watch?v=jNQXAC9IVRw");
await page.locator(".ytdl-thumbnail-download .settings").click();
await page.frameLocator("#ytdl-menu iframe").locator("#download-form").waitFor({ state: "visible" });
assert.equal(new URL(await page.locator("#ytdl-menu iframe").getAttribute("src")).searchParams.get("url"), "https://www.youtube.com/watch?v=jNQXAC9IVRw");
await page.frameLocator('#ytdl-menu iframe').locator('#download-form').waitFor({state:'visible'});
await page.frameLocator('#ytdl-menu iframe').locator('#folder').press('Escape');
await page.locator('#ytdl-menu').waitFor({state:'detached'});
await page.evaluate(() => {
const preview = document.createElement('ytm-shorts-lockup-view-model');
preview.style.cssText='display:block;width:120px;height:230px;position:relative';
preview.innerHTML = '<a href="/shorts/Mf9GCn_LsUI" style="display:block;height:200px"><yt-thumbnail-view-model><img alt="Short" style="width:120px;height:200px"></yt-thumbnail-view-model></a>';
preview.addEventListener('mouseenter', () => {
const replacement=document.createElement('a'); replacement.href='/shorts/Mf9GCn_LsUI'; replacement.style.cssText='display:block;height:200px';
replacement.innerHTML='<video style="width:120px;height:200px;background:#24364c"></video>';
preview.querySelector('a').replaceWith(replacement);
const player=document.createElement('div'); player.className='hover-player'; player.style.cssText='position:absolute;inset:0 0 30px;z-index:30;background:#24364c'; preview.append(player);
}, {once:true});
document.querySelector('main').prepend(preview);
});
await page.locator('ytm-shorts-lockup-view-model[data-ytdl-preview]').waitFor();
await page.locator('ytm-shorts-lockup-view-model').hover();
await page.locator('ytm-shorts-lockup-view-model .hover-player').waitFor();
const shortKey=await page.locator('ytm-shorts-lockup-view-model').getAttribute('data-ytdl-preview');
const shortButtons=page.locator(`[data-ytdl-preview-for="${shortKey}"]`);
assert.equal(await shortButtons.evaluate(node => node.parentElement === document.documentElement), true);
await shortButtons.locator('.download').click();
assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), 'https://www.youtube.com/watch?v=Mf9GCn_LsUI');
await shortButtons.locator('.settings').click();
await page.frameLocator('#ytdl-menu iframe').locator('#download-form').waitFor({state:'visible'});
await page.frameLocator('#ytdl-menu iframe').locator('#close').click();
await page.locator('#ytdl-menu').waitFor({state:'detached'});
await page.evaluate(() => {
history.pushState({}, "", "/shorts/jNQXAC9IVRw");
document.body.innerHTML = '<ytd-shorts><ytd-reel-video-renderer is-active><div id="actions" style="position:fixed;right:20px;bottom:180px"></div></ytd-reel-video-renderer></ytd-shorts>';
document.dispatchEvent(new Event("yt-navigate-finish"));
});
await page.locator("#actions #ytdl-shorts-download .download").click();
assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), "https://www.youtube.com/watch?v=jNQXAC9IVRw");
await page.locator("#ytdl-shorts-download .settings").click();
await page.frameLocator("#ytdl-menu iframe").locator("#download-form").waitFor({ state: "visible" });
await page.screenshot({ path: path.join(artifacts, "shorts-settings.png"), animations: "disabled" });
await page.evaluate(() => window.__contentMessage({ type: 'JOB_PROGRESS', job: {
id: 'component-complete', title: 'FFmpeg', kind: 'dependency', stage: 'complete', status: 'complete', percent: 100, result: { title: 'FFmpeg' }
} }));
assert.equal(await page.locator('#ytdl-notifications [data-id="component-complete"] .action').isVisible(), false);
const components = await context.newPage();
await components.addInitScript(() => { window.__dependencyMissing = true; });
await components.goto('https://ui.ytdl.test/downloader.html?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DjNQXAC9IVRw');
if (!await components.locator('#helper-panel').evaluate(panel => panel.open)) await components.locator('#helper-panel > summary').click();
await components.locator('.dependencies summary').click();
await components.locator('.dependency-row[data-dependency="ffmpeg"][data-status="missing"]').waitFor();
assert.equal(await components.locator('#download-form').isVisible(), false);
assert.equal(await components.locator('#engine option[value="nvenc"]').isDisabled(), true);
assert.equal(await components.locator('.dependency-row button').count(), 0);
assert.equal(await components.locator('#helper-install').textContent(), 'Восстановить');
assert.equal(await components.locator('#helper-install').getAttribute('href'), 'https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest');
assert.equal(await components.locator('#helper-install').getAttribute('target'), '_blank');
assert.equal(await components.locator('#helper-install').getAttribute('download'), null);
// A repaired bundle reports new capabilities after the helper restarts.
await components.evaluate(() => { window.__dependencyMissing = false; });
await components.locator('#helper-start').click();
await components.locator('#download-form').waitFor({state:'visible'});
assert.equal(await components.locator('.dependency-row[data-status="installed"]').count(), 5);
assert.equal(await components.locator('#engine option[value="nvenc"]').isDisabled(), false);
await components.close();
const amdPage=await context.newPage();
await amdPage.addInitScript(() => { window.__amdOnly=true; });
await amdPage.goto('https://ui.ytdl.test/downloader.html?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DjNQXAC9IVRw');
await amdPage.locator('#download-form').waitFor({state:'visible'});
assert.equal(await amdPage.locator('#engine option[value="nvenc"]').isDisabled(),true);
assert.equal(await amdPage.locator('#engine option[value="amf"]').isDisabled(),false);
await amdPage.locator('.advanced summary').click();
await amdPage.locator('#codec').selectOption('hevc'); await amdPage.locator('#engine').selectOption('amf');
assert.match(await amdPage.locator('#processing-hint').textContent(),/AMD AMF/);
await amdPage.locator('#codec').selectOption('vp9');
assert.match(await amdPage.locator('#processing-hint').textContent(),/недоступен/);
assert.equal(await amdPage.locator('#download').isDisabled(),true);
await amdPage.locator('#codec').selectOption('hevc');
assert.equal(await amdPage.locator('#download').isDisabled(),false);
await amdPage.locator('#helper-panel > summary').click();
assert.equal(await amdPage.locator('#helper-install').textContent(),'Переустановить');
await amdPage.locator('#auto-update').uncheck();
await amdPage.locator('#helper-update').click();
await amdPage.locator('#active-jobs .job-stage').filter({hasText:'Скачиваю FFmpeg'}).waitFor();
assert.equal(await amdPage.locator('#helper-update').isDisabled(),true);
assert.equal(await amdPage.evaluate(() => window.__updateRequests.length),1);
await amdPage.evaluate(() => window.__emitJob({id:'fixture-update',kind:'update',canCancel:false,status:'complete',stage:'complete',result:{title:'Все компоненты актуальны'}}));
await amdPage.locator('#helper-update:not([disabled])').waitFor();
assert.equal(await amdPage.locator('#active-jobs-section').isVisible(),false,'Completed component updates leave the active list');
assert.match(await amdPage.locator('#update-note').textContent(),/актуальны/);
await amdPage.close();
const loginPage = await context.newPage();
await loginPage.addInitScript(() => { window.__requiresLogin = true; window.__denySessionPermission = true; });
await loginPage.goto('https://ui.ytdl.test/downloader.html?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DjNQXAC9IVRw&theme=dark');
await loginPage.locator('#alert:not([hidden])').waitFor();
assert.equal(await loginPage.locator('#helper-status').textContent(), 'Помощник работает');
assert.equal(await loginPage.locator('#video-title').textContent(), 'Не удалось получить качество');
assert.match(await loginPage.locator('#alert').textContent(), /Использовать вход в YouTube/);
assert.ok(!(await loginPage.locator('#alert').textContent()).includes('cookies-from-browser'));
await loginPage.locator('#session-enable').click();
assert.match(await loginPage.locator('#alert').textContent(), /не разрешён/);
await loginPage.evaluate(() => { window.__denySessionPermission = false; });
await loginPage.locator('#session-enable').click();
await loginPage.locator('#download-form').waitFor({state:'visible'});
assert.equal(await loginPage.locator('#session-status').textContent(), 'Вход браузера подключён');
assert.equal(await loginPage.locator('#alert').isVisible(), false);
await loginPage.screenshot({path:path.join(artifacts,'session-connected.png'),animations:'disabled'});
await loginPage.locator('#session-disable').click();
await loginPage.locator('#session-panel[data-state="disabled"]').waitFor();
assert.equal(await loginPage.locator('#session-status').textContent(), 'Вход в YouTube не подключён');
await loginPage.close();
const storagePage = await context.newPage();
await storagePage.goto('https://ui.ytdl.test/downloader.html?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DjNQXAC9IVRw&theme=dark');
await storagePage.locator('#download-form').waitFor({state:'visible'});
await storagePage.locator('#folder').fill('C:\\Finished');
await storagePage.locator('#pick-work-folder').click();
await storagePage.waitForFunction(() => window.__fixtureStorage.preferences.workFolder === 'D:\\Videos\\YouTube');
assert.equal(await storagePage.locator('#folder').inputValue(), 'C:\\Finished');
await storagePage.locator('#download').click();
await storagePage.waitForFunction(() => window.__downloadRequests.length === 1);
const storageRequest = await storagePage.evaluate(() => window.__downloadRequests[0]);
assert.equal(storageRequest.folder, 'C:\\Finished');
assert.equal(storageRequest.workFolder, 'D:\\Videos\\YouTube');
await storagePage.close();
assert.deepEqual(errors, []);
console.log("UI checks passed: direct download + settings, stationary buttons, thumbnails, Shorts, real-state indicators, reconnect, themes and narrow layout.");
} finally { await browser.close(); }
+62
View File
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const browser = await chromium.launch({ headless: true, executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
const context = await browser.newContext({ viewport: { width: 360, height: 900 }, reducedMotion: "reduce" });
await context.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
await context.addInitScript(() => { window.__helperVersion = "1.3.0"; });
await context.route("https://ui.ytdl.test/**", async route => {
const name = new URL(route.request().url()).pathname.slice(1);
if (!["downloader.html", "downloader.js", "onboarding.js", "ui-connection.js", "theme-init.js", "styles.css", "shared.js", "session.js"].includes(name)) return route.abort();
return route.fulfill({ body: await fs.readFile(path.join(root, "extension", name)), contentType: name.endsWith(".html") ? "text/html" : name.endsWith(".css") ? "text/css" : "application/javascript" });
});
try {
const page = await context.newPage();
const errors = [];
page.on("pageerror", error => errors.push(error.message));
await page.goto("https://ui.ytdl.test/downloader.html?theme=dark&url=https://www.youtube.com/watch?v=aircAruvnKk");
await page.locator('#helper-panel[data-state="incompatible"]').waitFor();
assert.match(await page.locator('#helper-message').textContent(), /1\.3\.0.*1\.4\.5/);
assert.match(await page.locator('#helper-versions').textContent(), /Расширение 1\.4\.5.*Помощник 1\.3\.0/);
assert.equal(await page.locator('#helper-install').textContent(), 'Обновить помощник');
assert.equal(await page.locator('#download').isDisabled(), true);
assert.equal(await page.locator('#download-form').isVisible(), false);
await page.locator('#open-setup').click();
await page.locator('#setup-next').click();
await page.locator('#setup-helper[data-state="incompatible"]').waitFor();
assert.equal(await page.locator('#setup-next').isDisabled(), true);
assert.equal(await page.locator('#setup-install').textContent(), 'Обновить помощник');
assert.match(await page.locator('#setup-versions').textContent(), /1\.4\.5.*1\.3\.0/);
await page.evaluate(() => { window.__helperVersion = '1.3.2'; });
await page.locator('#setup-check').click();
await page.locator('#setup-helper[data-state="running"]').waitFor();
assert.match(await page.locator('#setup-versions').textContent(), /Версии совместимы/);
await page.locator('#setup-next:not([disabled])').waitFor();
await page.evaluate(() => { window.__helperVersion = '1.3.3'; });
await page.locator('#setup-check').click();
await page.locator('#setup-helper[data-state="incompatible"]').waitFor();
assert.equal(await page.locator('#setup-next').isDisabled(), true);
assert.equal(await page.locator('#setup-install').textContent(), 'Обновить расширение');
assert.equal(await page.locator('#setup-install-steps').isVisible(), false);
await page.locator('#setup-close').click();
assert.equal(await page.locator('#helper-install').textContent(), 'Обновить расширение');
assert.match(await page.locator('#helper-message').textContent(), /Помощник 1\.3\.3.*расширением 1\.4\.5/);
assert.equal(await page.locator('#download').isDisabled(), true);
await page.evaluate(() => { window.__helperVersion = '1.3.2'; });
await page.locator('#helper-start').click();
await page.locator('#helper-panel[data-state="running"]').waitFor();
await page.locator('#download:not([disabled])').waitFor();
assert.match(await page.locator('#helper-versions').textContent(), /Версии совместимы/);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await fs.mkdir(path.join(root, 'test-artifacts'), { recursive: true });
await page.screenshot({ path: path.join(root, 'test-artifacts/version-compatibility-360.png'), animations: 'disabled' });
assert.deepEqual(errors, []);
console.log('Version UI passed in Edge: both update directions, versions shown in settings and first-run wizard, downloads blocked until compatible, recovery without reload, 360px layout.');
} finally {
await context.close();
await browser.close();
}