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: '', status: "error", url: "https://evil.test/", error: 'Ошибка \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 }); }