mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Exhaustive QA sweep of all 157 tools. Fixes: CSP blob media, csv-excel ExcelJS interop, ocr-pdf segfault, chart-maker upload, non-PDF doc preview, RAW decode, merge-tool multi-file path, html-to-image chromium, ogv/wma/amr/ac3 preview fallbacks, meme/gif/stabilize codecs, nav+home a11y. Plus orphan-format and test-debt cleanup, the AI bundle build script, and a reusable Playwright QA harness under tests/qa/.
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
// 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, e.g. fixture("formats", "sample.png"). */
|
|
export function fixture(...parts: string[]): string {
|
|
return path.join(FIXTURES, ...parts);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<string, ToolMeta>(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<Hint, number> = {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
await page.locator(selector).setInputFiles(file);
|
|
}
|
|
|
|
/** Click the tool's submit/process button. */
|
|
export async function runTool(page: Page, toolId: string): Promise<void> {
|
|
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<ResultState> {
|
|
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<ResultState> {
|
|
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 <img> matching alt actually decoded (naturalWidth/Height > 0). */
|
|
export async function assertImageRendered(page: Page, alt: string, label = alt): Promise<ImgDims> {
|
|
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 <img> on the page is a broken placeholder. Catches silent preview failures. */
|
|
export async function assertNoBrokenImages(page: Page): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<DownloadedFile> {
|
|
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<string, (b: Buffer) => 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;
|
|
}
|
|
|
|
/** 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 ?? "";
|
|
return {
|
|
width: s.width ?? 0,
|
|
height: s.height ?? 0,
|
|
codec: s.codec_name ?? "",
|
|
pixFmt,
|
|
hasAlpha: /(rgba|bgra|argb|abgr|ya\d|yuva|gbrap)/i.test(pixFmt),
|
|
};
|
|
}
|
|
|
|
/** 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<string, string> };
|
|
streams?: Array<{
|
|
codec_type?: string;
|
|
width?: number;
|
|
height?: number;
|
|
channels?: number;
|
|
tags?: Record<string, string>;
|
|
}>;
|
|
} {
|
|
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);
|
|
}
|