From f5f31963826191557c3bdbe682dee670a4149b82 Mon Sep 17 00:00:00 2001 From: Fisto Date: Thu, 17 Sep 2026 08:18:30 +0200 Subject: [PATCH] Add the optional Playwright web UI smoke test --- AGENTS.md | 8 ++ docs/testing.md | 15 ++++ poc/package.json | 4 + poc/pnpm-lock.yaml | 12 +++ poc/test/media-ui-smoke.mjs | 141 ++++++++++++++++++++++++++++++++++++ 5 files changed, 180 insertions(+) create mode 100644 poc/test/media-ui-smoke.mjs diff --git a/AGENTS.md b/AGENTS.md index 601e174..5bb16b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,4 +119,12 @@ pnpm lint cd poc pnpm check # type check the daemon pnpm test:comm # unit tests +pnpm test:ui # optional browser smoke test for the Media tab ``` + +`pnpm test:ui` is optional. It needs a running daemon with media data and a Chromium: set +`PW_CHROMIUM` to a binary, or keep one in the Playwright cache (`~/.cache/ms-playwright`). +`playwright-core` is an optional dependency, so a missing browser never blocks the other tests. +`VESKSLOP_UI_URL` overrides the default `https://127.0.0.1:8789/`. It opens the Media tab, loads +tiles, applies the cached-only filter, checks the zoom slider and Reset, and fails on console +errors or broken images. See [docs/testing.md](docs/testing.md). diff --git a/docs/testing.md b/docs/testing.md index 2a57063..b3536c5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -177,6 +177,21 @@ Notes: locally and point `LD_LIBRARY_PATH` at it. - The live start was verified on 2026-09-13 through the web UI route. +## Web UI browser smoke test + +Optional. `playwright-core` is an optional dependency and the test needs a Chromium (`PW_CHROMIUM`, +or a Playwright cache under `~/.cache/ms-playwright`). The daemon must be running with media data. + +```sh +cd poc +pnpm test:ui +VESKSLOP_UI_URL=https://192.168.0.178:8789/ pnpm test:ui +``` + +It opens the Media tab, loads tiles, verifies no broken images, applies the cached-only filter, +checks that the zoom slider changes the grid density, presses Reset, and fails on any console +error. A screenshot lands in `/tmp/opencode/ui-smoke.png`. + ## Slopbot live check The bot core needs a real token and an invite to the test guild. The check logs in, lists guilds, diff --git a/poc/package.json b/poc/package.json index b27b745..80abe65 100644 --- a/poc/package.json +++ b/poc/package.json @@ -19,6 +19,7 @@ "test:media": "tsx ../media-scrape/test/run.ts && tsx ../media-scrape/test/slopbot.ts", "test:slopbot:live": "tsx ../media-scrape/test/live-slopbot.ts", "test:slopbot:count": "tsx ../media-scrape/test/live-count.ts", + "test:ui": "node test/media-ui-smoke.mjs", "audio:doctor": "bash scripts/audio-doctor.sh" }, "dependencies": { @@ -30,6 +31,9 @@ "ws": "^8.19.0", "zod": "^4.6.2" }, + "optionalDependencies": { + "playwright-core": "^1.63.0" + }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^22.19.0", diff --git a/poc/pnpm-lock.yaml b/poc/pnpm-lock.yaml index fa030c8..6183b42 100644 --- a/poc/pnpm-lock.yaml +++ b/poc/pnpm-lock.yaml @@ -51,6 +51,10 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + optionalDependencies: + playwright-core: + specifier: ^1.63.0 + version: 1.63.0 packages: @@ -599,6 +603,11 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -1266,6 +1275,9 @@ snapshots: pkce-challenge@5.0.1: {} + playwright-core@1.63.0: + optional: true + pngjs@5.0.0: {} proxy-addr@2.0.7: diff --git a/poc/test/media-ui-smoke.mjs b/poc/test/media-ui-smoke.mjs new file mode 100644 index 0000000..861df37 --- /dev/null +++ b/poc/test/media-ui-smoke.mjs @@ -0,0 +1,141 @@ +/* + * 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") results.consoleErrors.push(message.text()); + }); + page.on("pageerror", error => results.consoleErrors.push(String(error))); + + 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); + + await page.getByRole("button", { name: "Advanced search" }).click(); + await page.waitForSelector(".vk-media-drawer:not([hidden])", { timeout: 5000 }); + step("people checkboxes", await page.locator(".vk-media-people input").count()); + + await page.locator(".vk-media-advanced summary").click(); + 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); + + 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"); +}