Publish YouTubeDL 1.4.5 and website on Gitea
Check extension and helper / check (push) Canceled after 0s
Check extension and helper / check (push) Canceled after 0s
This commit is contained in:
commit
5f2aad00f4
97 files changed
+8451
No files matched your search
@@ -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.")
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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.")
|
||||
@@ -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(); }
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)); }
|
||||
@@ -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.' }
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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(); }
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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: "Помощник отключён." } }));
|
||||
})();
|
||||
@@ -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(); }
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user