// Shared helpers for the exhaustive tool QA sweep. Discovery shards import these // to upload files, run tools, assert previews render (the user's #1 concern), // verify downloads, and capture console/network issues. Designed for the // isolated QA container (auth off) on http://localhost:13499. import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { expect, type Page } from "@playwright/test"; export const REPO_ROOT = path.join(__dirname, "..", ".."); export const FIXTURES = path.join(REPO_ROOT, "tests", "fixtures"); /** Absolute path to a fixture. Accepts current paths and legacy QA aliases. */ export function fixture(...parts: string[]): string { const direct = path.join(FIXTURES, ...parts); if (fs.existsSync(direct)) return direct; const [scope, ...rest] = parts; const legacyScopes: Record = { content: [path.join("image", "valid"), path.join("document", "valid")], formats: [path.join("image", "formats"), path.join("image", "valid")], media: [ path.join("video", "formats"), path.join("video", "valid"), path.join("audio", "formats"), path.join("audio", "valid"), ], documents: [ path.join("document", "formats"), path.join("document", "valid"), path.join("document", "edge"), path.join("document", "hostile"), ], data: [path.join("data", "valid")], }; for (const candidateScope of legacyScopes[scope] ?? []) { const candidate = path.join(FIXTURES, candidateScope, ...rest); if (fs.existsSync(candidate)) return candidate; } if (parts.length === 1) { const legacyBareScopes = [ path.join("image", "valid"), path.join("image", "edge"), path.join("image", "formats"), path.join("document", "valid"), path.join("document", "formats"), path.join("document", "edge"), path.join("video", "valid"), path.join("video", "formats"), path.join("audio", "valid"), path.join("audio", "formats"), path.join("data", "valid"), ]; for (const candidateScope of legacyBareScopes) { const candidate = path.join(FIXTURES, candidateScope, parts[0]); if (fs.existsSync(candidate)) return candidate; } } return direct; } // --------------------------------------------------------------------------- // Tool metadata (generated by extract-tools-meta.mts). Tools route as // /:modality/:toolId, so navigation needs each tool's modality. // --------------------------------------------------------------------------- export interface ToolMeta { id: string; name: string; modality: "image" | "video" | "audio" | "document" | "file"; acceptedInputs: string[]; executionHint: "fast" | "long"; isAI: boolean; } export const TOOLS_META: ToolMeta[] = JSON.parse( fs.readFileSync(path.join(__dirname, "tools-meta.json"), "utf8"), ); const TOOL_BY_ID = new Map(TOOLS_META.map((t) => [t.id, t])); export function toolMeta(toolId: string): ToolMeta { const m = TOOL_BY_ID.get(toolId); if (!m) throw new Error(`Unknown toolId "${toolId}" (not in tools-meta.json)`); return m; } /** The /:modality/:toolId URL path for a tool. */ export function toolPath(toolId: string): string { const m = toolMeta(toolId); return `/${m.modality}/${m.id}`; } export type Hint = "fast" | "long" | "ai"; const HINT_TIMEOUT: Record = { fast: 30_000, long: 180_000, ai: 600_000, }; // --------------------------------------------------------------------------- // Console / network instrument: attach to every page; assert clean at the end. // --------------------------------------------------------------------------- export interface PageIssues { consoleErrors: string[]; pageErrors: string[]; failedRequests: string[]; serverErrors: string[]; } /** Returns true if no console error / page error / network failure / 5xx seen. */ export function isClean(i: PageIssues): boolean { return ( i.consoleErrors.length === 0 && i.pageErrors.length === 0 && i.failedRequests.length === 0 && i.serverErrors.length === 0 ); } export function issuesSummary(i: PageIssues): string { const parts: string[] = []; if (i.consoleErrors.length) parts.push(`console: ${i.consoleErrors.join(" | ")}`); if (i.pageErrors.length) parts.push(`pageerror: ${i.pageErrors.join(" | ")}`); if (i.failedRequests.length) parts.push(`netfail: ${i.failedRequests.join(" | ")}`); if (i.serverErrors.length) parts.push(`5xx: ${i.serverErrors.join(" | ")}`); return parts.join("\n"); } // Benign console noise that is not a product bug. const CONSOLE_IGNORE = [ /Download the React DevTools/i, /\[vite\]/i, /favicon/i, /ResizeObserver loop/i, ]; /** Attach listeners that collect issues for the lifetime of the page. */ export function instrument(page: Page): PageIssues { const issues: PageIssues = { consoleErrors: [], pageErrors: [], failedRequests: [], serverErrors: [], }; page.on("console", (msg) => { if (msg.type() !== "error") return; const text = msg.text(); if (CONSOLE_IGNORE.some((re) => re.test(text))) return; issues.consoleErrors.push(text); }); page.on("pageerror", (err) => { issues.pageErrors.push(String(err?.message ?? err)); }); page.on("requestfailed", (req) => { const f = req.failure(); if (!f) return; // ERR_ABORTED is normal for cancelled navigations / superseded requests. if (/ERR_ABORTED|net::ERR_ABORTED/.test(f.errorText)) return; issues.failedRequests.push(`${req.method()} ${req.url()} ${f.errorText}`); }); page.on("response", (resp) => { if (resp.status() >= 500) issues.serverErrors.push(`${resp.status()} ${resp.url()}`); }); return issues; } // --------------------------------------------------------------------------- // Navigation / upload / run // --------------------------------------------------------------------------- export async function gotoTool(page: Page, toolId: string): Promise { await page.goto(toolPath(toolId), { waitUntil: "domcontentloaded" }); } /** Upload one or more files via the dropzone file chooser. */ export async function uploadFiles(page: Page, files: string | string[]): Promise { const arr = Array.isArray(files) ? files : [files]; const chooserPromise = page.waitForEvent("filechooser"); const uploadBtn = page.getByRole("button", { name: /upload from computer/i }).first(); if (await uploadBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { await uploadBtn.click(); } else { await page.locator("[class*='border-dashed']").first().click(); } const chooser = await chooserPromise; await chooser.setFiles(arr); } /** Set a secondary file input by selector (multi-input tools, e.g. "#compose-overlay-image"). */ export async function setSecondaryInput(page: Page, selector: string, file: string): Promise { await page.locator(selector).setInputFiles(file); } /** Click the tool's submit/process button. */ export async function runTool(page: Page, toolId: string): Promise { const submit = page.getByTestId(`${toolId}-submit`); await submit.first().waitFor({ state: "visible", timeout: 15_000 }); await submit.first().click(); } export interface ResultState { ok: boolean; error?: string; } /** * Wait for processing to resolve to either success (a download/result control * appears) or a surfaced error. Distinguishes a real error from a slow tool via * per-hint timeouts. Never hangs forever. */ export async function waitForResult( page: Page, toolId: string, hint: Hint = "fast", ): Promise { const timeout = HINT_TIMEOUT[hint]; const download = page.getByTestId(`${toolId}-download`).first(); const genericDownload = page.locator("[data-download-button]").first(); const errorRegion = page .locator("[role='alert'], [aria-live='assertive']") .filter({ hasText: /error|failed|invalid|unsupported|not supported|too large/i }) .first(); const deadline = Date.now() + timeout; while (Date.now() < deadline) { if (await download.isVisible().catch(() => false)) return { ok: true }; if (await genericDownload.isVisible().catch(() => false)) return { ok: true }; if (await errorRegion.isVisible().catch(() => false)) { const error = await errorRegion.innerText().catch(() => "error surfaced"); return { ok: false, error: error.trim() }; } await page.waitForTimeout(500); } return { ok: false, error: `timeout after ${timeout}ms (hint=${hint})` }; } /** Convenience: run + wait. */ export async function processTool( page: Page, toolId: string, hint: Hint = "fast", ): Promise { await runTool(page, toolId); return waitForResult(page, toolId, hint); } // --------------------------------------------------------------------------- // Preview oracles (per modality). "Rendered" means actually decoded, not a // broken-image placeholder / dead player / "Load failed". // --------------------------------------------------------------------------- export interface ImgDims { w: number; h: number; } /** Assert an matching alt actually decoded (naturalWidth/Height > 0). */ export async function assertImageRendered(page: Page, alt: string, label = alt): Promise { const img = page.locator(`img[alt="${alt}"]`).first(); await expect(img, `${label} image visible`).toBeVisible({ timeout: 15_000 }); const dims = await img.evaluate((el: HTMLImageElement) => ({ w: el.naturalWidth, h: el.naturalHeight, })); expect(dims.w, `${label} naturalWidth > 0 (0 means broken image)`).toBeGreaterThan(0); expect(dims.h, `${label} naturalHeight > 0`).toBeGreaterThan(0); return dims; } /** Assert no on the page is a broken placeholder. Catches silent preview failures. */ export async function assertNoBrokenImages(page: Page): Promise { const broken = await page.evaluate(() => Array.from(document.images) .filter((im) => im.complete && im.naturalWidth === 0 && !!im.currentSrc) .map((im) => im.currentSrc), ); expect(broken, `broken images present: ${broken.join(", ")}`).toHaveLength(0); } /** Assert the video player loaded a decodable frame. */ export async function assertVideoPreview(page: Page): Promise { const v = page.getByTestId("media-player-video").first(); await expect(v, "video player visible").toBeVisible({ timeout: 15_000 }); await expect .poll(async () => v.evaluate((el: HTMLVideoElement) => el.readyState), { timeout: 25_000 }) .toBeGreaterThanOrEqual(1); const vw = await v.evaluate((el: HTMLVideoElement) => el.videoWidth); expect(vw, "video has decoded dimensions").toBeGreaterThan(0); } /** Assert the audio waveform reached ready state (play control enabled). */ export async function assertAudioPreview(page: Page): Promise { const wf = page.getByTestId("waveform-container").first(); await expect(wf, "waveform container visible").toBeVisible({ timeout: 15_000 }); const play = page.getByTestId("waveform-play-pause").first(); await expect(play, "waveform play control enabled (decoded)").toBeEnabled({ timeout: 25_000 }); } /** Assert the pdf.js document canvas rendered a non-blank page and did not fail to load. */ export async function assertDocumentPreview(page: Page): Promise { const canvas = page.getByTestId("document-canvas").first(); await expect(canvas, "document canvas visible").toBeVisible({ timeout: 25_000 }); await expect(page.locator(".text-destructive"), "no document load error").toHaveCount(0); const nonBlank = await canvas.evaluate((c: HTMLCanvasElement) => { const ctx = c.getContext("2d"); if (!ctx || c.width === 0 || c.height === 0) return false; const data = ctx.getImageData(0, 0, c.width, c.height).data; for (let i = 0; i < data.length; i += 4) { if (data[i] !== 255 || data[i + 1] !== 255 || data[i + 2] !== 255) return true; } return false; }); expect(nonBlank, "document canvas is non-blank").toBe(true); } // --------------------------------------------------------------------------- // Download + decode verification (ground truth = the downloaded artifact). // --------------------------------------------------------------------------- export interface DownloadedFile { path: string; name: string; size: number; buf: Buffer; } export async function downloadResult(page: Page, toolId: string): Promise { const dlPromise = page.waitForEvent("download", { timeout: 30_000 }); const btn = page.getByTestId(`${toolId}-download`).first(); if (await btn.isVisible().catch(() => false)) { await btn.click(); } else { await page.locator("[data-download-button]").first().click(); } const dl = await dlPromise; const name = dl.suggestedFilename(); const out = path.join(os.tmpdir(), `qa-${toolId}-${process.hrtime.bigint()}-${name}`); await dl.saveAs(out); const buf = fs.readFileSync(out); return { path: out, name, size: buf.length, buf }; } const MAGIC: Record boolean> = { png: (b) => b.length > 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47, jpg: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff, jpeg: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff, gif: (b) => b.subarray(0, 3).toString("latin1") === "GIF", webp: (b) => b.subarray(0, 4).toString("latin1") === "RIFF" && b.subarray(8, 12).toString("latin1") === "WEBP", bmp: (b) => b[0] === 0x42 && b[1] === 0x4d, tiff: (b) => (b[0] === 0x49 && b[1] === 0x49) || (b[0] === 0x4d && b[1] === 0x4d), pdf: (b) => b.subarray(0, 5).toString("latin1") === "%PDF-", zip: (b) => b[0] === 0x50 && b[1] === 0x4b, avif: (b) => b.subarray(4, 12).toString("latin1").includes("ftyp"), heic: (b) => b.subarray(4, 12).toString("latin1").includes("ftyp"), ico: (b) => b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00, mp4: (b) => b.subarray(4, 8).toString("latin1") === "ftyp", mp3: (b) => (b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0), wav: (b) => b.subarray(0, 4).toString("latin1") === "RIFF" && b.subarray(8, 12).toString("latin1") === "WAVE", flac: (b) => b.subarray(0, 4).toString("latin1") === "fLaC", ogg: (b) => b.subarray(0, 4).toString("latin1") === "OggS", }; /** Best-effort magic-byte check for a downloaded artifact's format. Unknown ext => true. */ export function magicMatches(buf: Buffer, ext: string): boolean { const key = ext.replace(/^\./, "").toLowerCase(); const fn = MAGIC[key]; return fn ? fn(buf) : true; } // --------------------------------------------------------------------------- // Decode oracles on downloaded bytes (semantic correctness, not DOM). // --------------------------------------------------------------------------- export interface ImageInfo { width: number; height: number; codec: string; pixFmt: string; hasAlpha: boolean; } function sharpHasAlpha(file: string): boolean { try { const script = ` import sharp from "sharp"; const meta = await sharp(process.argv[1]).metadata(); console.log(JSON.stringify({ hasAlpha: meta.hasAlpha === true })); `; const out = execFileSync(process.execPath, ["--input-type=module", "-e", script, file], { cwd: path.join(REPO_ROOT, "apps", "api"), encoding: "utf8", timeout: 20_000, }); return JSON.parse(out).hasAlpha === true; } catch { return false; } } /** Image dimensions/format/alpha via ffprobe (png/jpg/webp/gif/tiff/bmp/avif/heic/...). */ export function imageInfo(file: string): ImageInfo { const out = execFileSync( "ffprobe", [ "-v", "quiet", "-select_streams", "v:0", "-show_entries", "stream=width,height,codec_name,pix_fmt", "-print_format", "json", file, ], { encoding: "utf8", timeout: 20_000 }, ); const s = (JSON.parse(out).streams ?? [])[0] ?? {}; const pixFmt: string = s.pix_fmt ?? ""; const hasAlpha = /(rgba|bgra|argb|abgr|ya\d|yuva|gbrap)/i.test(pixFmt) || (s.codec_name === "png" && sharpHasAlpha(file)); return { width: s.width ?? 0, height: s.height ?? 0, codec: s.codec_name ?? "", pixFmt, hasAlpha, }; } /** First-frame luma (YAVG 0-255) and saturation (SATAVG 0-255) via ffmpeg signalstats. */ export function signalStats(file: string): { luma: number; saturation: number } { const esc = file.replace(/\\/g, "\\\\").replace(/:/g, "\\:").replace(/'/g, "\\'"); const out = execFileSync( "ffprobe", [ "-v", "quiet", "-f", "lavfi", "-i", `movie=${esc},signalstats`, "-show_entries", "frame_tags=lavfi.signalstats.YAVG,lavfi.signalstats.SATAVG", "-read_intervals", "%+#1", "-print_format", "json", ], { encoding: "utf8", timeout: 20_000 }, ); const tags = (JSON.parse(out).frames ?? [])[0]?.tags ?? {}; return { luma: Number(tags["lavfi.signalstats.YAVG"] ?? 0), saturation: Number(tags["lavfi.signalstats.SATAVG"] ?? 0), }; } /** Mean luminance (0-255); for brightness/contrast oracles. */ export function imageLuma(file: string): number { return signalStats(file).luma; } /** Mean saturation (0-255); ~0 for grayscale, for desaturate/colorize oracles. */ export function imageSaturation(file: string): number { return signalStats(file).saturation; } /** ffprobe a media file on disk: returns parsed JSON (format + streams). */ export function probeMedia(file: string): { format?: { duration?: string; tags?: Record }; streams?: Array<{ codec_type?: string; width?: number; height?: number; channels?: number; tags?: Record; }>; } { const out = execFileSync( "ffprobe", ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", file], { encoding: "utf8", timeout: 20_000 }, ); return JSON.parse(out); } /** Media duration in seconds (for trim/speed/fade oracles). */ export function mediaDuration(file: string): number { const info = probeMedia(file); return Number(info.format?.duration ?? 0); }