/* * Media tab browser smoke test (optional). * * Needs a running daemon whose web UI has media data, and a Chromium binary. * VESKSLOP_UI_URL web UI base URL (default https://127.0.0.1:8789/) * PW_CHROMIUM Chromium binary; defaults to the Playwright cache under * ~/.cache/ms-playwright, then to Playwright's own resolution * * Run from poc/: pnpm test:ui */ import { chromium } from "playwright-core"; import { existsSync, readdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; const URL = process.env.VESKSLOP_UI_URL ?? "https://127.0.0.1:8789/"; const results = { steps: [], consoleErrors: [], failures: [] }; function chromiumPath() { if (process.env.PW_CHROMIUM) return process.env.PW_CHROMIUM; const cache = join(homedir(), ".cache", "ms-playwright"); try { for (const entry of readdirSync(cache)) { if (!entry.startsWith("chromium-")) continue; const candidate = join(cache, entry, "chrome-linux", "chrome"); if (existsSync(candidate)) return candidate; } } catch { // no cache; fall through to the Playwright default } return undefined; } function step(name, value) { results.steps.push({ name, value }); console.log(`- ${name}: ${JSON.stringify(value)}`); } function check(name, condition, detail) { if (condition) return; results.failures.push(`${name}${detail === undefined ? "" : ` (${JSON.stringify(detail)})`}`); console.error(`FAIL: ${name}`, detail ?? ""); } const browser = await chromium.launch({ executablePath: chromiumPath(), headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"] }); try { const context = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1400, height: 900 } }); const page = await context.newPage(); page.on("console", message => { if (message.type() !== "error") return; // Resource errors carry no URL; the response listener checks those precisely. if (message.text().startsWith("Failed to load resource")) return; results.consoleErrors.push(message.text()); }); page.on("pageerror", error => results.consoleErrors.push(String(error))); page.on("response", response => { if (response.status() < 400) return; // The grid falls back to the original CDN link when nothing is cached; expired // signed links 404 by design and the tile shows its placeholder. if (/^https:\/\/(cdn|media)\.discordapp\.(com|net)\//.test(response.url())) return; results.consoleErrors.push(`HTTP ${response.status()} ${response.url()}`); }); await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForSelector("#scope-media", { timeout: 20000 }); await page.click("#scope-media"); await page.waitForSelector(".vk-media-overlay:not([hidden])", { timeout: 10000 }); const promptVisible = await page.locator(".vk-media-prompt").isVisible(); step("prompt visible", promptVisible); check("prompt is shown before loading", promptVisible); await page.getByRole("button", { name: "Load media" }).click(); await page.waitForSelector(".vk-media-figure", { timeout: 45000 }); const loaded = await page.locator(".vk-media-figure").count(); await page.waitForTimeout(2500); step("figures", loaded); step("status", (await page.locator("#media-status").textContent())?.trim()); check("media tiles render", loaded > 0, loaded); const broken = await page.$$eval(".vk-media-media img", images => images.filter(image => image.naturalWidth === 0).length ); step("broken images", broken); check("no broken images", broken === 0, broken); check("stream buffer present", (await page.locator(".vk-media-buffer").count()) === 1); check("no load-more button", (await page.locator(".vk-media-load-more").count()) === 0); // Infinite scroll: scrolling into the buffer appends cards without rebuilding the grid. const beforeScroll = await page.locator(".vk-media-figure").count(); for (let attempt = 0; attempt < 3; attempt++) { await page.locator("#media-grid").evaluate(node => { node.scrollTop = node.scrollHeight; }); await page.waitForTimeout(1200); } const afterScroll = await page.locator(".vk-media-figure").count(); step("infinite scroll", { before: beforeScroll, after: afterScroll }); check("infinite scroll appends cards", afterScroll > beforeScroll, { before: beforeScroll, after: afterScroll }); check( "appended cards do not corrupt loaded ones", (await page.$$eval(".vk-media-figure .vk-media-media img", images => images.filter(image => image.complete && image.naturalWidth === 0).length )) === 0 ); await page.locator("#media-grid").evaluate(node => { node.scrollTop = 0; }); await page.waitForTimeout(300); await page.getByRole("button", { name: /^People/ }).click(); step("people checkboxes", await page.locator(".vk-media-people input").count()); await page.getByRole("button", { name: /^People/ }).click(); const providerFilters = await page.evaluate(async () => { const response = await fetch("/api/media/status"); const body = await response.json(); return body?.result?.providerFilters ?? []; }); step("provider filters from the API", providerFilters); await page.getByRole("button", { name: "Advanced search" }).click(); await page.waitForSelector(".vk-media-drawer:not([hidden])", { timeout: 5000 }); check( "title search field present", (await page.locator('.vk-media-drawer input[type="text"]').count()) >= 1 ); check( "exact-match toggle present", (await page.locator(".vk-media-drawer .vk-media-check", { hasText: "Exact match" }).count()) === 1 ); const sourceBoxes = await page .locator(".vk-media-exclude-group") .first() .locator(".vk-media-check") .count(); step("source checkboxes", sourceBoxes); check( "provider checkboxes match the API list", sourceBoxes === providerFilters.length + 3, { sourceBoxes, providers: providerFilters.length } ); const includeSources = await page .locator(".vk-media-exclude-group") .nth(2) .locator(".vk-media-check") .count(); step("must-include source checkboxes", includeSources); check( "must-include mirrors the provider list", includeSources === providerFilters.length + 3, { includeSources, providers: providerFilters.length } ); await page.locator(".vk-media-check", { hasText: "Show only cached previews" }).locator("input").check(); await page.getByRole("button", { name: "Apply filters" }).click(); await page.waitForTimeout(2500); const cachedFigures = await page.locator(".vk-media-figure").count(); step("figures cachedOnly", cachedFigures); check("cachedOnly keeps results", cachedFigures > 0, cachedFigures); check( "cachedOnly hides uncached tiles", (await page.$$eval(".vk-media-media img", images => images.filter(image => image.naturalWidth === 0).length )) === 0 ); await page.getByRole("button", { name: "Advanced search" }).click(); const zoom = page.locator(".vk-media-zoom"); const columns = {}; for (const value of ["0", "100"]) { await zoom.evaluate((node, v) => { node.value = v; node.dispatchEvent(new Event("input", { bubbles: true })); }, value); await page.waitForTimeout(400); columns[value] = await page .locator("#media-grid") .evaluate(node => getComputedStyle(node).gridTemplateColumns.split(" ").length); } step("columns by zoom", columns); check("zoom changes the grid density", columns["0"] > columns["100"], columns); await page.getByRole("button", { name: "Reset" }).click(); await page.waitForTimeout(800); const resetPrompt = await page.locator(".vk-media-prompt").isVisible(); step("prompt after reset", resetPrompt); check("reset returns the fresh view", resetPrompt); // Bot tab: backfill progress, stats, and the three settings groups. await page.click("#scope-bot"); await page.waitForSelector("#media-bot:not([hidden])", { timeout: 10000 }); await page.waitForFunction( () => document.querySelector("#backfill-summary")?.textContent?.includes("pending"), { timeout: 20000 } ); check("media toolbar hidden on the bot tab", !(await page.locator("#media-toolbar").isVisible())); check("media filter drawer hidden on the bot tab", !(await page.locator(".vk-media-drawer").isVisible())); await page.waitForFunction( () => document.querySelector("#title-lane")?.textContent?.includes("Titles (YouTube)"), { timeout: 15000 } ); await page.waitForFunction( () => document.querySelector("#recover-lane")?.textContent?.includes("Recover Discord previews"), { timeout: 15000 } ); check("recovery card renders", true); const withMessage = await page.evaluate(async () => { const response = await fetch("/api/media?kind=video&limit=5"); const body = await response.json(); return (body.result ?? []).filter(item => item.messageUrl).length; }); step("items with a message link", withMessage); check("message links present", withMessage > 0, withMessage); check("title lane card renders", (await page.locator("#title-lane .vk-bot-job-head").count()) === 1); const summaryLines = await page.locator(".vk-bot-summary-line").count(); step("backfill summary lines", summaryLines); check("backfill summary renders", summaryLines >= 3, summaryLines); step("backfill totals", (await page.locator("#backfill-summary").textContent())?.trim().slice(0, 160)); step("backend split", (await page.locator(".vk-bot-summary-line.vk-dim").first().textContent())?.trim()); const etaTitles = await page.locator(".vk-bot-eta").evaluateAll(nodes => nodes.map(node => node.getAttribute("title") ?? "") ); step("eta tooltips", etaTitles); check("queue eta tooltip", etaTitles.some(title => title.includes("queue drains in"))); if ((await page.locator(".vk-bot-job").count()) === 0) { step("job eta tooltip", "no pending jobs"); } else { check("job eta tooltip", etaTitles.some(title => /starts in .* runs for/.test(title))); } const groups = await page.locator("#bot-tunables details").count(); step("tunable groups", groups); check("three tunable groups", groups === 3, groups); const fields = await page.locator("#bot-tunables input").count(); step("tunable fields", fields); check("tunable fields render", fields >= 12, fields); check("start button visible", await page.locator("#backfill-start").isVisible()); step("console errors", results.consoleErrors.length); check("no console errors", results.consoleErrors.length === 0, results.consoleErrors.slice(0, 3)); await page.screenshot({ path: "/tmp/opencode/ui-smoke.png" }); } finally { await browser.close(); } if (results.failures.length) { console.error(`ui smoke failed: ${results.failures.length} check(s)`); process.exitCode = 1; } else { console.log("ui smoke passed"); }