Files
SnapOtter/tests/qa/qa-helpers.ts
T
SnapOtterandGitHub d8cf979d4b fix: resolve 18 QA-discovered bugs across tools, previews, and the AI pipeline (#242)
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/.
2026-06-15 22:26:24 +08:00

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);
}