diff --git a/apps/api/src/lib/csp.ts b/apps/api/src/lib/csp.ts index a7be80be..5b6df1f7 100644 --- a/apps/api/src/lib/csp.ts +++ b/apps/api/src/lib/csp.ts @@ -13,17 +13,17 @@ const SCALAR_FONT_ORIGIN = "https://fonts.scalar.com"; * would require forking the Scalar plugin, which is not practical. */ export function buildCsp(isDocs: boolean): string { - const connectSrc = ["'self'", "data:", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" "); + const connectSrc = ["'self'", "blob:", "data:", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" "); const fontSrc = isDocs ? `'self' data: ${SCALAR_FONT_ORIGIN}` : "'self' data:"; const scriptSrc = isDocs ? "'self' 'unsafe-inline' https://us-assets.i.posthog.com" : "'self' https://us-assets.i.posthog.com"; if (isDocs) { - return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; base-uri 'self'; form-action 'self'`; + return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; base-uri 'self'; form-action 'self'`; } - return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://tile.openstreetmap.org; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`; + return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://tile.openstreetmap.org; media-src 'self' blob:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`; } export function getSecurityHeaders(): Record { diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index b18465f7..42cfc463 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -243,6 +243,13 @@ export async function validateImageBuffer( detectedFormat = "png"; } + // RAW formats with non-TIFF magic bytes (Panasonic RW2, some Olympus ORF, + // Pentax PEF, etc.) are not caught by MAGIC_BYTES. Fall back to + // extension-based detection for known Camera RAW extensions. + if (!detectedFormat && ext && isRawExtension(ext)) { + detectedFormat = "raw"; + } + if (!detectedFormat) { return { valid: false, reason: "Unrecognized image format" }; } diff --git a/apps/api/src/lib/format-decoders.ts b/apps/api/src/lib/format-decoders.ts index 14865843..6f64d402 100644 --- a/apps/api/src/lib/format-decoders.ts +++ b/apps/api/src/lib/format-decoders.ts @@ -211,7 +211,41 @@ async function decodeRaw(buffer: Buffer, ext?: string): Promise { // ExifTool not available or no embedded JPEG -- fall through } - // Attempt 2: ImageMagick + LibRaw delegate (full decode) + // Attempt 1b: ExifTool PreviewImage extraction (some formats store + // preview under a different tag than JpgFromRaw). + try { + const { stdout } = await execFileAsync("exiftool", ["-b", "-PreviewImage", inputPath], { + encoding: "buffer", + maxBuffer: 50 * 1024 * 1024, + timeout: 30_000, + } as never); + const previewBuf = stdout as unknown as Buffer; + if (previewBuf && previewBuf.length > 1000) { + if (previewBuf[0] === 0xff && previewBuf[1] === 0xd8) { + return previewBuf; + } + } + } catch { + // fall through + } + + // Attempt 2: dcraw_emu from libraw-bin (direct LibRaw decode to TIFF). + // More reliable than ImageMagick's delegate chain for Camera RAW. + try { + await execFileAsync("dcraw_emu", ["-T", "-w", "-o", "1", inputPath], { timeout: 120_000 }); + // dcraw_emu writes output next to the input with a .tiff extension + const dcrawOutput = inputPath.replace(/\.[^.]+$/, ".tiff"); + const tiffBuf = await readFile(dcrawOutput); + await rm(dcrawOutput, { force: true }).catch(() => {}); + if (tiffBuf.length > 0) { + // Convert TIFF to PNG via Sharp (Sharp handles TIFF natively) + return await sharp(tiffBuf).png().toBuffer(); + } + } catch { + // dcraw_emu not available or unsupported format -- fall through + } + + // Attempt 3: ImageMagick + LibRaw delegate (full decode) const cmd = await findMagickCmd(); await execFileAsync( cmd, diff --git a/apps/api/src/routes/tools/csv-excel.ts b/apps/api/src/routes/tools/csv-excel.ts index a10c29f3..264b2454 100644 --- a/apps/api/src/routes/tools/csv-excel.ts +++ b/apps/api/src/routes/tools/csv-excel.ts @@ -20,8 +20,10 @@ export function registerCsvExcel(app: FastifyInstance) { const base = input.filename.replace(/\.[^.]+$/, ""); const lower = input.filename.toLowerCase(); - // Dynamic import: exceljs is heavy; load it only when this tool runs - const ExcelJS = await import("exceljs"); + // Dynamic import: exceljs is heavy; load it only when this tool runs. + // exceljs is CJS, so under ESM the constructor lives on the default export + // (namespace `.Workbook` is undefined -> "is not a constructor"). + const { default: ExcelJS } = await import("exceljs"); if (lower.endsWith(".xlsx")) { // xlsx -> csv: load workbook, pick the Nth worksheet, extract rows diff --git a/apps/api/src/routes/tools/meme-generator.ts b/apps/api/src/routes/tools/meme-generator.ts index 8b033d87..b8bb00f9 100644 --- a/apps/api/src/routes/tools/meme-generator.ts +++ b/apps/api/src/routes/tools/meme-generator.ts @@ -170,9 +170,20 @@ async function processMeme( gif: "image/gif", }; + // Ensure the output filename extension matches the actual raster format + // (e.g. SVG input produces a PNG buffer, so the name must end in .png). + const extMap: Record = { + jpeg: ".jpg", + png: ".png", + webp: ".webp", + gif: ".gif", + }; + const correctExt = extMap[detectedFormat] ?? ".png"; + const outFilename = filename.replace(/\.[^.]+$/, correctExt); + return { buffer: result, - filename, + filename: outFilename, contentType: mimeMap[detectedFormat] ?? "image/png", }; } diff --git a/apps/api/src/routes/tools/stabilize-video.ts b/apps/api/src/routes/tools/stabilize-video.ts index 4badeea0..047582db 100644 --- a/apps/api/src/routes/tools/stabilize-video.ts +++ b/apps/api/src/routes/tools/stabilize-video.ts @@ -1,5 +1,5 @@ import { extname, join } from "node:path"; -import { probeMedia, resolveEncoder, runFfmpeg } from "@snapotter/media-engine"; +import { type EncoderTarget, probeMedia, resolveEncoder, runFfmpeg } from "@snapotter/media-engine"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; @@ -9,6 +9,44 @@ const settingsSchema = z.object({ smoothing: z.number().int().min(5).max(60).default(15), }); +/** + * Choose a codec that is valid for the given container extension. + * webm requires VP9 (or VP8/AV1); ogv requires Theora; everything + * else gets H.264 (mp4/mov/mkv/avi/ts). + */ +function codecForContainer(ext: string): { + target: EncoderTarget; + encodeArgs: string[]; +} { + const lower = ext.toLowerCase(); + if (lower === ".webm") { + return { + target: "vp9", + encodeArgs: ["-c:v", resolveEncoder("vp9"), "-crf", "30", "-b:v", "0", "-row-mt", "1"], + }; + } + if (lower === ".ogv" || lower === ".ogg") { + // Theora has no HW-accel path; use libtheora directly. + return { + target: "h264", // unused, just for the type + encodeArgs: ["-c:v", "libtheora", "-q:v", "7"], + }; + } + return { + target: "h264", + encodeArgs: [ + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + ], + }; +} + export function registerStabilizeVideo(app: FastifyInstance) { createToolRoute(app, { toolId: "stabilize-video", @@ -38,9 +76,19 @@ export function registerStabilizeVideo(app: FastifyInstance) { }, ); - // Pass 2: stabilization with re-encode + // Pass 2: stabilization with re-encode using a container-appropriate codec. ctx.report(50, "Stabilizing"); const outPath = join(ctx.scratchDir, "media", outName); + const { encodeArgs } = codecForContainer(origExt); + + // Audio: webm/ogv need Opus/Vorbis; for other containers just copy. + const audioArgs = + origExt.toLowerCase() === ".webm" + ? ["-c:a", resolveEncoder("opus")] + : origExt.toLowerCase() === ".ogv" || origExt.toLowerCase() === ".ogg" + ? ["-c:a", "libvorbis"] + : ["-c:a", "copy"]; + await runFfmpegWithProgress( ctx, [ @@ -48,16 +96,8 @@ export function registerStabilizeVideo(app: FastifyInstance) { inPath, "-vf", `vidstabtransform=input=${trf}:smoothing=${settings.smoothing}`, - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", - "-c:a", - "copy", + ...encodeArgs, + ...audioArgs, outPath, ], info.durationS, diff --git a/apps/api/src/routes/tools/video-to-gif.ts b/apps/api/src/routes/tools/video-to-gif.ts index d6f8b31b..e309d4fe 100644 --- a/apps/api/src/routes/tools/video-to-gif.ts +++ b/apps/api/src/routes/tools/video-to-gif.ts @@ -23,13 +23,19 @@ export function registerVideoToGif(app: FastifyInstance) { const outName = `${base}.gif`; const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => { + // Place -ss/-t AFTER -i (output-side seeking) so ffmpeg decodes + // from the start. Input-side seeking relies on a keyframe index + // which FLV (and some other legacy containers) often lack, causing + // zero decoded frames and exit 234. return [ + "-fflags", + "+genpts", + "-i", + inPath, "-ss", String(settings.startS), "-t", String(settings.durationS), - "-i", - inPath, "-vf", `fps=${settings.fps},scale=${settings.width}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`, out, diff --git a/apps/web/src/components/common/waveform-player.tsx b/apps/web/src/components/common/waveform-player.tsx index 2de34c2b..2c7fbd99 100644 --- a/apps/web/src/components/common/waveform-player.tsx +++ b/apps/web/src/components/common/waveform-player.tsx @@ -1,6 +1,7 @@ -import { Pause, Play } from "lucide-react"; +import { Download, Pause, Play, Volume2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import WaveSurfer from "wavesurfer.js"; +import { useTranslation } from "@/contexts/i18n-context"; import { cn } from "@/lib/utils"; interface WaveformPlayerProps { @@ -14,13 +15,19 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, "0")}`; } +/** Timeout (ms) after which we assume WaveSurfer cannot decode the audio. */ +const DECODE_TIMEOUT_MS = 15_000; + export function WaveformPlayer({ src, className }: WaveformPlayerProps) { + const { t } = useTranslation(); const containerRef = useRef(null); const wsRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [isReady, setIsReady] = useState(false); + const [decodeError, setDecodeError] = useState(false); + const readyRef = useRef(false); // Detect dark mode from the document class (toggled by useTheme) const isDark = @@ -31,6 +38,9 @@ export function WaveformPlayer({ src, className }: WaveformPlayerProps) { useEffect(() => { if (!containerRef.current) return; + readyRef.current = false; + setDecodeError(false); + const ws = WaveSurfer.create({ container: containerRef.current, waveColor, @@ -48,10 +58,19 @@ export function WaveformPlayer({ src, className }: WaveformPlayerProps) { ws.load(src); ws.on("ready", () => { + readyRef.current = true; setDuration(ws.getDuration()); setIsReady(true); }); + // F8: surface decode errors for formats WaveSurfer cannot handle + // (wma, amr, ac3, etc.) + ws.on("error", () => { + if (!readyRef.current) { + setDecodeError(true); + } + }); + ws.on("audioprocess", () => { setCurrentTime(ws.getCurrentTime()); }); @@ -72,9 +91,19 @@ export function WaveformPlayer({ src, className }: WaveformPlayerProps) { setIsPlaying(false); }); + // Decode timeout: if WaveSurfer hasn't fired "ready" after a generous + // window, the format is likely unsupported. + const timer = setTimeout(() => { + if (!readyRef.current) { + setDecodeError(true); + } + }, DECODE_TIMEOUT_MS); + return () => { + clearTimeout(timer); ws.destroy(); wsRef.current = null; + readyRef.current = false; setIsPlaying(false); setCurrentTime(0); setDuration(0); @@ -86,6 +115,30 @@ export function WaveformPlayer({ src, className }: WaveformPlayerProps) { wsRef.current?.playPause(); }, []); + // F8: graceful fallback when the browser cannot decode the audio + if (decodeError) { + return ( +
+
+
+
+ +
+

{t.toolPage.audioDecodeUnsupported}

+ + + {t.common.download} + +
+
+
+ ); + } + return (
diff --git a/apps/web/src/components/layout/top-nav.tsx b/apps/web/src/components/layout/top-nav.tsx index 7c05ec1c..69a71d99 100644 --- a/apps/web/src/components/layout/top-nav.tsx +++ b/apps/web/src/components/layout/top-nav.tsx @@ -95,7 +95,7 @@ export function TopNav({
) : ( - + )} @@ -128,7 +128,7 @@ export function TopNav({ )} > {/* Left: Logo */} - + diff --git a/apps/web/src/components/tools/chart-maker-settings.tsx b/apps/web/src/components/tools/chart-maker-settings.tsx index 679b7ae2..b7cde991 100644 --- a/apps/web/src/components/tools/chart-maker-settings.tsx +++ b/apps/web/src/components/tools/chart-maker-settings.tsx @@ -17,7 +17,7 @@ const INPUT_CLASS = export function ChartMakerSettings() { const { t } = useTranslation(); const { files } = useFileStore(); - const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = + const { processFiles, processing, error, downloadUrl, progress } = useToolProcessor("chart-maker"); const [kind, setKind] = useState("bar"); @@ -28,11 +28,7 @@ export function ChartMakerSettings() { const handleProcess = () => { const settings: Record = { kind, width, height }; if (title.trim()) settings.title = title.trim(); - if (files.length > 1) { - processAllFiles(files, settings); - } else { - processFiles(files, settings); - } + processFiles(files, settings); }; const hasFile = files.length > 0; @@ -138,9 +134,7 @@ export function ChartMakerSettings() { disabled={!canProcess} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - {files.length > 1 - ? t.toolSettings["chart-maker"].submitBatch.replace("{count}", String(files.length)) - : t.toolSettings["chart-maker"].submit} + {t.toolSettings["chart-maker"].submit} )} diff --git a/apps/web/src/components/tools/create-zip-settings.tsx b/apps/web/src/components/tools/create-zip-settings.tsx index 05427a68..8086bfdc 100644 --- a/apps/web/src/components/tools/create-zip-settings.tsx +++ b/apps/web/src/components/tools/create-zip-settings.tsx @@ -1,26 +1,18 @@ import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; -import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; export function CreateZipSettings() { const { t } = useTranslation(); const s = t.toolSettings["create-zip"]; const { files } = useFileStore(); - const { processFiles, processAllFiles, processing, error, progress } = - useToolProcessor("create-zip"); + const { processFiles, processing, error, progress } = useToolProcessor("create-zip"); const hasFile = files.length > 0; - const hasMultiple = files.length > 1; const handleProcess = () => { - const settings = {}; - if (hasMultiple) { - processAllFiles(files, settings); - } else { - processFiles(files, settings); - } + processFiles(files, {}); }; return ( @@ -44,7 +36,7 @@ export function CreateZipSettings() { disabled={!hasFile || processing} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed" > - {hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit} + {s.submit} )}
diff --git a/apps/web/src/components/tools/document-view.tsx b/apps/web/src/components/tools/document-view.tsx index 3670739e..623591e4 100644 --- a/apps/web/src/components/tools/document-view.tsx +++ b/apps/web/src/components/tools/document-view.tsx @@ -1,3 +1,4 @@ +import { FileText } from "lucide-react"; import * as pdfjs from "pdfjs-dist"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "@/contexts/i18n-context"; @@ -16,8 +17,14 @@ export function DocumentView() { const [page, setPage] = useState(1); const [pageCount, setPageCount] = useState(0); const [error, setError] = useState(null); + + const hasProcessedUrl = !!entry?.processedUrl; const src = entry?.processedUrl ?? entry?.blobUrl; + // F6: detect non-PDF input files (word, excel, html, markdown, etc.) + const isPdfInput = entry?.file?.name?.toLowerCase().endsWith(".pdf") ?? false; + const showInputFallback = !isPdfInput && !hasProcessedUrl; + /* A+B: reset pagination and clear stale errors when the document changes. src is intentionally a trigger-only dep (not read inside the callback). */ // biome-ignore lint/correctness/useExhaustiveDependencies: src is the trigger @@ -29,8 +36,10 @@ export function DocumentView() { /* C+D: cancel in-flight renders and destroy the doc proxy on cleanup. */ useEffect(() => { - if (!canvasRef.current) return; - const file = entry?.file; + if (!canvasRef.current || showInputFallback) return; + // F22: when processedUrl is available, use URL-based loading instead of + // entry.file (which is the original non-PDF input and would fail pdf.js) + const file = hasProcessedUrl ? undefined : entry?.file; if (!file && !src) return; let cancelled = false; let doc: pdfjs.PDFDocumentProxy | undefined; @@ -63,10 +72,26 @@ export function DocumentView() { renderTask?.cancel(); doc?.loadingTask.destroy(); }; - }, [src, page, entry?.file]); + }, [src, page, entry?.file, showInputFallback, hasProcessedUrl]); if (!entry) return null; + // F6: graceful fallback for non-PDF input files + if (showInputFallback) { + const ext = entry.file?.name?.split(".").pop()?.toUpperCase() ?? ""; + return ( +
+
+ +
+ {ext &&

{ext}

} +

+ {t.tools.documentView.inputNotPreviewable} +

+
+ ); + } + return (
{error &&

{t.tools.documentView.loadFailed}

} diff --git a/apps/web/src/components/tools/media-player-view.tsx b/apps/web/src/components/tools/media-player-view.tsx index 7dadfc1c..d535f386 100644 --- a/apps/web/src/components/tools/media-player-view.tsx +++ b/apps/web/src/components/tools/media-player-view.tsx @@ -1,16 +1,40 @@ +import { useRef, useState } from "react"; +import { NonNativePreview } from "@/components/common/non-native-preview"; import { useTranslation } from "@/contexts/i18n-context"; import { useFileStore } from "@/stores/file-store"; /** * Native
diff --git a/apps/web/src/lib/tool-display-modes.ts b/apps/web/src/lib/tool-display-modes.ts index ba774a5b..421fa9d0 100644 --- a/apps/web/src/lib/tool-display-modes.ts +++ b/apps/web/src/lib/tool-display-modes.ts @@ -193,7 +193,7 @@ export const TOOL_DISPLAY_MODES: Record = { "to-epub": "no-comparison", // Data tools - "chart-maker": "no-dropzone", + "chart-maker": "no-comparison", "csv-excel": "no-comparison", "csv-json": "no-comparison", "json-xml": "no-comparison", diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 882142a4..255ec4a2 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -119,6 +119,7 @@ export function HomePage() { return (
+

{t.homePage.heading}

toolAccept?.split(",").map((e) => e.trim().replace(/^\./, "").toLowerCase()), + () => + toolAccept + ?.split(",") + .map((e) => e.trim().replace(/^\./, "").toLowerCase()) + .filter(Boolean), [toolAccept], ); const toolFileFilter = useMemo(() => { + if (acceptsAnyFile) return () => true; if (!toolAcceptExts || toolAcceptExts.length === 0) return undefined; return (file: File) => { const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; return toolAcceptExts.includes(ext); }; - }, [toolAcceptExts]); + }, [toolAcceptExts, acceptsAnyFile]); const toolAcceptDescription = useMemo( () => - toolAcceptExts + toolAcceptExts && toolAcceptExts.length > 0 ? `${toolAcceptExts.map((e) => e.toUpperCase()).join(", ")} files only` : undefined, [toolAcceptExts], @@ -415,16 +421,18 @@ export function ToolPage() { const input = document.createElement("input"); input.type = "file"; input.multiple = true; - input.accept = - toolAccept ?? - "image/*,.avif,.heic,.heif,.hif,.jxl,.dng,.cr2,.cr3,.nef,.nrw,.arw,.orf,.rw2,.raf,.pef,.3fr,.iiq,.srw,.x3f,.rwl,.gpr,.fff,.mrw,.mef,.kdc,.dcr,.erf,.ptx,.tga,.psd,.exr,.hdr,.svgz,.jp2,.j2k,.qoi,.eps,.dds,.cur,.apng,.dpx,.cin,.fits,.ppm,.pgm,.pbm,.pfm"; + if (!acceptsAnyFile) { + input.accept = + toolAccept ?? + "image/*,.avif,.heic,.heif,.hif,.jxl,.dng,.cr2,.cr3,.nef,.nrw,.arw,.orf,.rw2,.raf,.pef,.3fr,.iiq,.srw,.x3f,.rwl,.gpr,.fff,.mrw,.mef,.kdc,.dcr,.erf,.ptx,.tga,.psd,.exr,.hdr,.svgz,.jp2,.j2k,.qoi,.eps,.dds,.cur,.apng,.dpx,.cin,.fits,.ppm,.pgm,.pbm,.pfm"; + } input.onchange = (e) => { const selected = Array.from((e.target as HTMLInputElement).files || []); const newFiles = toolFileFilter ? selected.filter(toolFileFilter) : selected; if (newFiles.length > 0) addFiles(newFiles); }; input.click(); - }, [addFiles, toolAccept, toolFileFilter]); + }, [addFiles, toolAccept, toolFileFilter, acceptsAnyFile]); // Page-level drag handlers (active when a file is already loaded) const handleDragEnter = useCallback((e: React.DragEvent) => { diff --git a/docker/Dockerfile b/docker/Dockerfile index 6fe8a379..5c2bdac9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -200,7 +200,7 @@ RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $( tini \ imagemagick \ libjxl-tools \ - libraw-dev \ + libraw-dev libraw-bin \ libopenexr-dev \ potrace \ ghostscript \ @@ -309,9 +309,16 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store/v3 \ npm pkg delete scripts.prepare && \ pnpm install --frozen-lockfile --prod -# Install Playwright Chromium for HTML-to-Image tool -RUN npx playwright install chromium --with-deps && \ - rm -rf /tmp/* /root/.cache/ms-playwright/.links +# Install Playwright Chromium for HTML-to-Image tool. +# PLAYWRIGHT_BROWSERS_PATH puts browsers in a shared location so the +# non-root snapotter user can find and execute them at runtime. +# Use the workspace's pinned Playwright (not `npx playwright`, which fetches a +# NEWER version and installs a chromium build the runtime playwright cannot +# resolve) so the installed browser matches chromium.executablePath(). +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers +RUN pnpm --filter @snapotter/api exec playwright install chromium --with-deps && \ + chmod -R a+rX /opt/playwright-browsers && \ + rm -rf /tmp/* # Copy source code for API (tsx runs TS directly - no build step needed) COPY apps/api/src ./apps/api/src diff --git a/docker/build-bundle.sh b/docker/build-bundle.sh index 417bc44e..bcccbc65 100755 --- a/docker/build-bundle.sh +++ b/docker/build-bundle.sh @@ -174,6 +174,12 @@ bundle = manifest["bundles"][os.environ["BUNDLE_ID"]] models = bundle.get("models", []) models_dir = os.environ["MODELS_DIR"] +# Point rembg's model home at the staging dir so downloaded ONNX models +# end up inside the tarball (default U2NET_HOME is outside MODELS_DIR). +rembg_home = os.path.join(models_dir, "rembg") +os.makedirs(rembg_home, exist_ok=True) +os.environ["U2NET_HOME"] = rembg_home + if not models: print(" No models to download") sys.exit(0) @@ -221,9 +227,27 @@ for model in models: args = model["args"] session_name = args[0] print(f" [{model_id}] rembg session: {session_name}", flush=True) - from rembg.sessions import new_session - new_session(session_name) - print(f" [{model_id}] Done") + + # birefnet-matting and birefnet-hr-matting are custom sessions + # registered at runtime in remove_bg.py (not in rembg's built-in + # session registry). new_session() cannot resolve them, so + # download their ONNX files directly using the same URLs and + # filenames the custom session classes use. + CUSTOM_BIREFNET = { + "birefnet-matting": "https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet-matting-epoch_100.onnx", + "birefnet-hr-matting": "https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet_HR-matting-epoch_135.onnx", + } + if session_name in CUSTOM_BIREFNET: + dest = os.path.join(models_dir, "rembg", f"{session_name}.onnx") + os.makedirs(os.path.dirname(dest), exist_ok=True) + print(f" [{model_id}] Custom BiRefNet -> rembg/{session_name}.onnx", flush=True) + urllib.request.urlretrieve(CUSTOM_BIREFNET[session_name], dest) + size = os.path.getsize(dest) + print(f" [{model_id}] Done ({size:,} bytes)") + else: + from rembg.sessions import new_session + new_session(session_name) + print(f" [{model_id}] Done") else: print(f" WARNING: Unknown download method for {model_id}", file=sys.stderr) diff --git a/packages/ai/python/ocr_pdf.py b/packages/ai/python/ocr_pdf.py index 1d7f2b86..d2b9a3ff 100644 --- a/packages/ai/python/ocr_pdf.py +++ b/packages/ai/python/ocr_pdf.py @@ -142,20 +142,19 @@ def main(): elif was_auto: current_lang = language # already detected - # Run OCR engine based on quality tier - if quality == "fast": - text = ocr_module.run_tesseract(png_path, current_lang, is_auto=was_auto) - engine_used = "tesseract" - elif quality == "balanced": - text = ocr_module.run_paddleocr_v5(png_path, current_lang) - engine_used = "paddleocr-v5" - elif quality == "best": - text = ocr_module.run_paddleocr_vl(png_path) - engine_used = "paddleocr-vl" - else: + # Always route PDF OCR through tesseract. PaddleOCR segfaults + # on arm64 CPU when processing rasterised PDF pages (SIGSEGV in + # the doc-orientation / structural-analysis stage). Tesseract is + # reliable for page-level images and is already installed in the + # container with multi-language packs. The image-OCR tool still + # offers PaddleOCR tiers for single images where it is stable. + if quality not in ("fast", "balanced", "best"): print(json.dumps({"error": f"Unknown quality: {quality}"})) sys.exit(1) + text = ocr_module.run_tesseract(png_path, current_lang, is_auto=was_auto) + engine_used = "tesseract" + page_texts.append((page_num, text)) # Clean up scratch PNG immediately diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index af586508..1cad21fb 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -722,6 +722,7 @@ export const ar: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2470,6 +2471,7 @@ export const ar: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "جاري إنشاء المعاينة...", @@ -2488,6 +2490,7 @@ export const ar: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "البحث في الأدوات...", @@ -3445,5 +3448,6 @@ export const ar: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index dc09ed3e..ad145da9 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -727,6 +727,7 @@ export const de: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2484,6 +2485,7 @@ export const de: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Vorschau wird generiert...", @@ -2502,6 +2504,7 @@ export const de: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Werkzeuge suchen...", @@ -3476,5 +3479,6 @@ export const de: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index a6fbe8e4..74309a0d 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -684,6 +684,7 @@ export const en = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2434,6 +2435,7 @@ export const en = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Generating preview...", @@ -2452,6 +2454,7 @@ export const en = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Search tools...", @@ -3412,6 +3415,7 @@ export const en = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index 7a3e8a7a..a13c4b70 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -712,6 +712,7 @@ export const es: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2466,6 +2467,7 @@ export const es: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Generando vista previa...", @@ -2484,6 +2486,7 @@ export const es: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Buscar herramientas...", @@ -3454,5 +3457,6 @@ export const es: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index adacc9c7..78a53a9e 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -728,6 +728,7 @@ export const fr: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2485,6 +2486,7 @@ export const fr: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Generation de l'apercu...", @@ -2503,6 +2505,7 @@ export const fr: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Rechercher des outils...", @@ -3475,5 +3478,6 @@ export const fr: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index 3ce1c3c8..cc6589eb 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -719,6 +719,7 @@ export const hi: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2467,6 +2468,7 @@ export const hi: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "प्रीव्यू जनरेट हो रहा है...", @@ -2485,6 +2487,7 @@ export const hi: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "टूल्स खोजें...", @@ -3442,5 +3445,6 @@ export const hi: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index 029edcbf..94b5208a 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -727,6 +727,7 @@ export const id: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2479,6 +2480,7 @@ export const id: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Membuat pratinjau...", @@ -2497,6 +2499,7 @@ export const id: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Cari alat...", @@ -3460,5 +3463,6 @@ export const id: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index f432a615..ed3ecb04 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -726,6 +726,7 @@ export const it: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2478,6 +2479,7 @@ export const it: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Generazione anteprima...", @@ -2496,6 +2498,7 @@ export const it: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Cerca strumenti...", @@ -3469,5 +3472,6 @@ export const it: TranslationKeys = { settingsDialog: "Impostazioni", helpDialog: "Guida", navigationMenu: "Menu di navigazione", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 8832779a..9377fc3c 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -687,6 +687,7 @@ export const ja: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2437,6 +2438,7 @@ export const ja: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "プレビューを生成中...", @@ -2455,6 +2457,7 @@ export const ja: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "ツールを検索...", @@ -3417,6 +3420,7 @@ export const ja: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index 15890909..0d424cff 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -674,6 +674,7 @@ export const ko: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2422,6 +2423,7 @@ export const ko: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "미리보기 생성 중...", @@ -2440,6 +2442,7 @@ export const ko: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "도구 검색...", @@ -3401,6 +3404,7 @@ export const ko: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 0332c2e0..286fa41a 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -727,6 +727,7 @@ export const nl: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2481,6 +2482,7 @@ export const nl: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Preview genereren...", @@ -2499,6 +2501,7 @@ export const nl: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Gereedschap zoeken...", @@ -3463,5 +3466,6 @@ export const nl: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 7de7b2c7..0ba67d10 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -728,6 +728,7 @@ export const pl: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2482,6 +2483,7 @@ export const pl: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Generowanie podglądu...", @@ -2500,6 +2502,7 @@ export const pl: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Szukaj narzędzi...", @@ -3471,5 +3474,6 @@ export const pl: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 4798a8d7..dc284dc4 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -725,6 +725,7 @@ export const ptBR: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2478,6 +2479,7 @@ export const ptBR: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Gerando visualizacao...", @@ -2496,6 +2498,7 @@ export const ptBR: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Buscar ferramentas...", @@ -3464,5 +3467,6 @@ export const ptBR: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index 6678c4fe..e341f5a8 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -727,6 +727,7 @@ export const ru: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2480,6 +2481,7 @@ export const ru: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Генерация предпросмотра...", @@ -2498,6 +2500,7 @@ export const ru: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Поиск инструментов...", @@ -3462,5 +3465,6 @@ export const ru: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index a7c4ba61..0b17f00c 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -725,6 +725,7 @@ export const sv: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2477,6 +2478,7 @@ export const sv: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Genererar forhandsvisning...", @@ -2495,6 +2497,7 @@ export const sv: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Sok verktyg...", @@ -3457,5 +3460,6 @@ export const sv: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index a158861c..52e6e521 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -720,6 +720,7 @@ export const th: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2459,6 +2460,7 @@ export const th: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "กำลังสร้างตัวอย่าง...", @@ -2477,6 +2479,7 @@ export const th: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "ค้นหาเครื่องมือ...", @@ -3433,5 +3436,6 @@ export const th: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 17bed48c..c6cd762c 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -728,6 +728,7 @@ export const tr: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2482,6 +2483,7 @@ export const tr: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Önizleme oluşturuluyor...", @@ -2500,6 +2502,7 @@ export const tr: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Araç ara...", @@ -3468,5 +3471,6 @@ export const tr: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index d8567e30..68829d62 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -727,6 +727,7 @@ export const uk: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2480,6 +2481,7 @@ export const uk: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Генерація попереднього перегляду...", @@ -2498,6 +2500,7 @@ export const uk: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Пошук інструментів...", @@ -3463,5 +3466,6 @@ export const uk: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 0e26cef9..0dafedeb 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -728,6 +728,7 @@ export const vi: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2479,6 +2480,7 @@ export const vi: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "Đang tạo bản xem trước...", @@ -2497,6 +2499,7 @@ export const vi: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "Tìm kiếm công cụ...", @@ -3457,5 +3460,6 @@ export const vi: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 5179ba2e..0b355683 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -674,6 +674,7 @@ export const zhCN: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2412,6 +2413,7 @@ export const zhCN: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "正在生成预览...", @@ -2430,6 +2432,7 @@ export const zhCN: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "搜索工具...", @@ -3383,5 +3386,6 @@ export const zhCN: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index 64bde2bf..4a654332 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -673,6 +673,7 @@ export const zhTW: TranslationKeys = { loadFailed: "Failed to load document.", previousPage: "Previous page", nextPage: "Next page", + inputNotPreviewable: "Preview not available for this file type", }, }, toolSettings: { @@ -2410,6 +2411,7 @@ export const zhTW: TranslationKeys = { downloadAsFile: "Download as file", downloadAll: "Download All", downloadFiles: "Download {count} files", + audioDecodeUnsupported: "This audio format cannot be previewed in the browser", }, homePage: { generatingPreview: "正在產生預覽...", @@ -2428,6 +2430,7 @@ export const zhTW: TranslationKeys = { documents: "Documents", data: "Data", toolCount: "{count} tools", + heading: "SnapOtter Tools", }, fullscreenGrid: { searchPlaceholder: "搜尋工具...", @@ -3382,6 +3385,7 @@ export const zhTW: TranslationKeys = { settingsDialog: "Settings", helpDialog: "Help", navigationMenu: "Navigation", + homeLink: "SnapOtter home", }, } as const; diff --git a/packages/shared/src/modality.ts b/packages/shared/src/modality.ts index 6549bf41..5f6fc866 100644 --- a/packages/shared/src/modality.ts +++ b/packages/shared/src/modality.ts @@ -114,8 +114,6 @@ export const DOCUMENT_INPUTS = [ ".md", ".html", ".epub", - ".mobi", - ".azw3", ]; export const FILE_INPUTS = [".csv", ".json", ".xml", ".yaml", ".yml", ".zip"]; diff --git a/tests/fixtures/content/alt-2page.pdf b/tests/fixtures/content/alt-2page.pdf new file mode 100644 index 00000000..99de3a50 Binary files /dev/null and b/tests/fixtures/content/alt-2page.pdf differ diff --git a/tests/fixtures/content/audio-with-tags.mp3 b/tests/fixtures/content/audio-with-tags.mp3 new file mode 100644 index 00000000..e3702d86 Binary files /dev/null and b/tests/fixtures/content/audio-with-tags.mp3 differ diff --git a/tests/fixtures/content/barcode.png b/tests/fixtures/content/barcode.png new file mode 100644 index 00000000..73e75e57 Binary files /dev/null and b/tests/fixtures/content/barcode.png differ diff --git a/tests/fixtures/content/media-30s.mp4 b/tests/fixtures/content/media-30s.mp4 new file mode 100644 index 00000000..b2997074 Binary files /dev/null and b/tests/fixtures/content/media-30s.mp4 differ diff --git a/tests/fixtures/content/media-30s.wav b/tests/fixtures/content/media-30s.wav new file mode 100644 index 00000000..704a975e Binary files /dev/null and b/tests/fixtures/content/media-30s.wav differ diff --git a/tests/fixtures/content/multipage-6.pdf b/tests/fixtures/content/multipage-6.pdf new file mode 100644 index 00000000..cb2e90a0 Binary files /dev/null and b/tests/fixtures/content/multipage-6.pdf differ diff --git a/tests/fixtures/content/ocr-clean.png b/tests/fixtures/content/ocr-clean.png new file mode 100644 index 00000000..ad673642 Binary files /dev/null and b/tests/fixtures/content/ocr-clean.png differ diff --git a/tests/fixtures/content/ocr-scanned.pdf b/tests/fixtures/content/ocr-scanned.pdf new file mode 100644 index 00000000..b38e328c Binary files /dev/null and b/tests/fixtures/content/ocr-scanned.pdf differ diff --git a/tests/fixtures/content/portrait-color-dup.jpg b/tests/fixtures/content/portrait-color-dup.jpg new file mode 100644 index 00000000..c4e97838 Binary files /dev/null and b/tests/fixtures/content/portrait-color-dup.jpg differ diff --git a/tests/fixtures/content/qr-code.png b/tests/fixtures/content/qr-code.png new file mode 100644 index 00000000..f4e0ae4f Binary files /dev/null and b/tests/fixtures/content/qr-code.png differ diff --git a/tests/fixtures/content/speech-10s.mp4 b/tests/fixtures/content/speech-10s.mp4 new file mode 100644 index 00000000..60571295 Binary files /dev/null and b/tests/fixtures/content/speech-10s.mp4 differ diff --git a/tests/fixtures/content/speech-10s.wav b/tests/fixtures/content/speech-10s.wav new file mode 100644 index 00000000..6b612af2 Binary files /dev/null and b/tests/fixtures/content/speech-10s.wav differ diff --git a/tests/fixtures/content/video-with-meta.mp4 b/tests/fixtures/content/video-with-meta.mp4 new file mode 100644 index 00000000..7cfc429f Binary files /dev/null and b/tests/fixtures/content/video-with-meta.mp4 differ diff --git a/tests/fixtures/hostile/garbage.pdf b/tests/fixtures/hostile/garbage.pdf new file mode 100644 index 00000000..92841560 Binary files /dev/null and b/tests/fixtures/hostile/garbage.pdf differ diff --git a/tests/fixtures/hostile/truncated.docx b/tests/fixtures/hostile/truncated.docx new file mode 100644 index 00000000..663fef82 Binary files /dev/null and b/tests/fixtures/hostile/truncated.docx differ diff --git a/tests/fixtures/hostile/truncated.mp4 b/tests/fixtures/hostile/truncated.mp4 new file mode 100644 index 00000000..4b09b814 Binary files /dev/null and b/tests/fixtures/hostile/truncated.mp4 differ diff --git a/tests/fixtures/hostile/zero-byte.wav b/tests/fixtures/hostile/zero-byte.wav new file mode 100644 index 00000000..e69de29b diff --git a/tests/helpers/tool-default-settings.ts b/tests/helpers/tool-default-settings.ts index 307fa300..31570120 100644 --- a/tests/helpers/tool-default-settings.ts +++ b/tests/helpers/tool-default-settings.ts @@ -34,6 +34,8 @@ export const TOOL_SETTINGS_OVERRIDES: Record = { "epub-convert": { format: "html" }, "convert-presentation": { format: "odp" }, "convert-spreadsheet": { format: "ods" }, + "content-aware-resize": { width: 50 }, + "ai-canvas-expand": { extendRight: 32 }, }; export function defaultSettingsFor(toolId: string): unknown { diff --git a/tests/qa/api-sweep.mts b/tests/qa/api-sweep.mts new file mode 100644 index 00000000..dab50ee7 --- /dev/null +++ b/tests/qa/api-sweep.mts @@ -0,0 +1,1020 @@ +/** + * Container API processing sweep for SnapOtter QA. + * + * For every (tool, accepted-format-with-fixture) combination, posts a file + * to the real Docker container and verifies the output. Runs serially. + * + * Usage: + * ./apps/api/node_modules/.bin/tsx tests/qa/api-sweep.mts + * + * Expects: snapotter-qa container at http://localhost:13499, AUTH_ENABLED=false. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +// ── Config ──────────────────────────────────────────────────────── +const BASE = "http://localhost:13499"; +const REPO = join(import.meta.dirname, "..", ".."); +const FIXTURES_FORMATS = join(REPO, "tests", "fixtures", "formats"); +const FIXTURES_MEDIA = join(REPO, "tests", "fixtures", "media"); +const FIXTURES_DOCS = join(REPO, "tests", "fixtures", "documents"); +const FIXTURES_DATA = join(REPO, "tests", "fixtures", "data"); +const OUT_DIR = join(REPO, "docs", "qa"); + +const FAST_TIMEOUT_MS = 60_000; +const LONG_TIMEOUT_MS = 180_000; +const AI_TIMEOUT_MS = 300_000; +const SSE_POLL_INTERVAL_MS = 2_000; + +// ── Types ───────────────────────────────────────────────────────── + +interface ToolMeta { + id: string; + name?: string; + modality: string; + acceptedInputs: string[]; + executionHint: string; + isAI: boolean; +} + +interface SweepResult { + tool: string; + format: string; + status: number | string; + outputOk: boolean | null; + note: string; +} + +type Classification = "pass" | "expected-reject" | "suspicious-reject" | "bug" | "skipped" | "needs-review"; + +// ── Load tools + settings ───────────────────────────────────────── + +const tools: ToolMeta[] = JSON.parse( + readFileSync(join(REPO, "tests", "qa", "tools-meta.json"), "utf8"), +); + +const TOOL_SETTINGS_OVERRIDES: Record = { + resize: { width: 64 }, + crop: { left: 0, top: 0, width: 50, height: 50 }, + convert: { format: "png" }, + "watermark-text": { text: "Test" }, + "text-overlay": { text: "Test" }, + "passport-photo": { countryCode: "us" }, + "trim-video": { startS: 0, endS: 5 }, + "trim-audio": { startS: 0, endS: 5 }, + "split-pdf": { mode: "range", range: "1" }, + "extract-pages": { range: "1" }, + "remove-pages": { pages: "2" }, + "organize-pdf": { order: "1-z" }, + "protect-pdf": { userPassword: "test123" }, + "unlock-pdf": { password: "test123" }, + "watermark-pdf": { text: "CONFIDENTIAL" }, + "redact-pdf": { terms: ["test"] }, + "crop-video": { width: 32, height: 32 }, + "rotate-video": { transform: "cw90" }, + "resize-video": { preset: "720p" }, + "watermark-video": { text: "CONFIDENTIAL" }, + "audio-channels": { mode: "mono-to-stereo" }, + "convert-document": { format: "odt" }, + "epub-convert": { format: "html" }, + "convert-presentation": { format: "odp" }, + "convert-spreadsheet": { format: "ods" }, +}; + +function defaultSettingsFor(toolId: string): unknown { + return TOOL_SETTINGS_OVERRIDES[toolId] ?? {}; +} + +// ── Extension aliases ───────────────────────────────────────────── +const EXT_ALIASES: Record = { + ".jpeg": ".jpg", + ".tif": ".tiff", + ".htm": ".html", + ".yml": ".yaml", + ".markdown": ".md", + ".heif": ".heic", +}; + +// ── Fixture resolution ──────────────────────────────────────────── + +function resolveFixture(ext: string, modality: string): string | null { + // Normalize extension + const canonical = EXT_ALIASES[ext] ?? ext; + const bare = canonical.slice(1); // remove leading dot + + // Modality-based fixture dirs + if (modality === "image") { + // Images use sample. in formats/ + const p = join(FIXTURES_FORMATS, `sample.${bare}`); + if (existsSync(p)) return p; + // Special: apng is in formats + if (bare === "apng") { + const pa = join(FIXTURES_FORMATS, "sample.apng"); + if (existsSync(pa)) return pa; + } + } + + if (modality === "video" || modality === "audio") { + const p = join(FIXTURES_MEDIA, `tiny.${bare}`); + if (existsSync(p)) return p; + } + + if (modality === "document") { + const p = join(FIXTURES_DOCS, `tiny.${bare}`); + if (existsSync(p)) return p; + } + + if (modality === "file" || modality === "data") { + const p = join(FIXTURES_DATA, `tiny.${bare}`); + if (existsSync(p)) return p; + } + + // Fallback: try all dirs + for (const dir of [FIXTURES_FORMATS, FIXTURES_MEDIA, FIXTURES_DOCS, FIXTURES_DATA]) { + for (const prefix of ["sample", "tiny"]) { + const p = join(dir, `${prefix}.${bare}`); + if (existsSync(p)) return p; + } + } + + return null; +} + +// ── Multipart builder (native, no deps) ─────────────────────────── + +function buildMultipart( + filePath: string, + filename: string, + settings: unknown, +): { body: Blob; contentType: string } { + const fileBytes = readFileSync(filePath); + const form = new FormData(); + form.append("file", new Blob([fileBytes]), filename); + form.append("settings", JSON.stringify(settings)); + // Return the FormData directly -- fetch handles it + return { body: form as unknown as Blob, contentType: "multipart/form-data" }; +} + +// ── Output verification ─────────────────────────────────────────── + +/** Known file signatures (magic bytes). */ +const SIGNATURES: Array<{ name: string; bytes: number[]; offset?: number }> = [ + { name: "PNG", bytes: [0x89, 0x50, 0x4e, 0x47] }, + { name: "JPEG", bytes: [0xff, 0xd8, 0xff] }, + { name: "GIF87a", bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] }, + { name: "GIF89a", bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] }, + { name: "BMP", bytes: [0x42, 0x4d] }, + { name: "TIFF-LE", bytes: [0x49, 0x49, 0x2a, 0x00] }, + { name: "TIFF-BE", bytes: [0x4d, 0x4d, 0x00, 0x2a] }, + { name: "WebP", bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF....WEBP + { name: "AVIF/HEIC", bytes: [0x00, 0x00, 0x00] }, // ftyp box (offset 4) + { name: "PDF", bytes: [0x25, 0x50, 0x44, 0x46] }, // %PDF + { name: "ZIP", bytes: [0x50, 0x4b, 0x03, 0x04] }, + { name: "ICO", bytes: [0x00, 0x00, 0x01, 0x00] }, + { name: "MP4/MOV", bytes: [0x66, 0x74, 0x79, 0x70], offset: 4 }, // ftyp at offset 4 + { name: "OGG", bytes: [0x4f, 0x67, 0x67, 0x53] }, + { name: "FLAC", bytes: [0x66, 0x4c, 0x61, 0x43] }, + { name: "WAV", bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF....WAVE + { name: "ID3/MP3", bytes: [0x49, 0x44, 0x33] }, + { name: "MP3-sync", bytes: [0xff, 0xfb] }, + { name: "MP3-sync2", bytes: [0xff, 0xf3] }, + { name: "MP3-sync3", bytes: [0xff, 0xf2] }, +]; + +function detectSignature(data: Buffer): string | null { + for (const sig of SIGNATURES) { + const off = sig.offset ?? 0; + if (data.length < off + sig.bytes.length) continue; + let match = true; + for (let i = 0; i < sig.bytes.length; i++) { + if (data[off + i] !== sig.bytes[i]) { match = false; break; } + } + if (match) return sig.name; + } + return null; +} + +function verifyOutput(data: Buffer, contentType: string): { ok: boolean; detail: string } { + if (data.length === 0) { + return { ok: false, detail: "empty output" }; + } + + const ct = (contentType || "").split(";")[0].trim().toLowerCase(); + + // ZIP check + if (ct === "application/zip" || ct === "application/x-zip-compressed") { + if (data.length >= 4 && data[0] === 0x50 && data[1] === 0x4b) { + return { ok: true, detail: `valid ZIP (${data.length} bytes)` }; + } + return { ok: false, detail: "ZIP content-type but invalid header" }; + } + + // JSON check + if (ct === "application/json") { + try { + JSON.parse(data.toString("utf8")); + return { ok: true, detail: `valid JSON (${data.length} bytes)` }; + } catch { + return { ok: false, detail: "JSON content-type but unparseable" }; + } + } + + // Text check + if (ct.startsWith("text/")) { + return data.length > 0 + ? { ok: true, detail: `text output (${data.length} bytes)` } + : { ok: false, detail: "empty text" }; + } + + // SVG check + if (ct === "image/svg+xml") { + const str = data.toString("utf8").slice(0, 500); + if (str.includes("= 5 && data.toString("ascii", 0, 5) === "%PDF-") { + return { ok: true, detail: `valid PDF (${data.length} bytes)` }; + } + return { ok: false, detail: "PDF content-type but missing %PDF- header" }; + } + + // Binary: check file signature + const sig = detectSignature(data); + if (sig) { + return { ok: true, detail: `${sig} signature (${data.length} bytes)` }; + } + + // Octet-stream / unknown: just check non-trivial size + if (data.length >= 16) { + return { ok: true, detail: `binary output (${data.length} bytes, no known signature)` }; + } + + return { ok: false, detail: `suspiciously small binary output (${data.length} bytes)` }; +} + +// ── SSE polling for async jobs ──────────────────────────────────── + +async function pollJobSSE(jobId: string, timeoutMs: number): Promise<{ + status: "completed" | "failed" | "timeout"; + error?: string; + result?: Record; +}> { + const deadline = Date.now() + timeoutMs; + const url = `${BASE}/api/v1/jobs/${jobId}/progress`; + + while (Date.now() < deadline) { + try { + const controller = new AbortController(); + const fetchTimeout = setTimeout(() => controller.abort(), Math.min(30_000, deadline - Date.now())); + + const res = await fetch(url, { + signal: controller.signal, + headers: { Accept: "text/event-stream" }, + }); + clearTimeout(fetchTimeout); + + if (!res.ok || !res.body) { + await sleep(SSE_POLL_INTERVAL_MS); + continue; + } + + // Read SSE stream + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (Date.now() < deadline) { + const readTimeout = Math.min(30_000, deadline - Date.now()); + const readPromise = reader.read(); + const timeoutPromise = sleep(readTimeout).then(() => ({ done: true, value: undefined } as const)); + const chunk = await Promise.race([readPromise, timeoutPromise]); + + if (chunk.done) break; + if (chunk.value) { + buffer += decoder.decode(chunk.value as Uint8Array, { stream: true }); + } + + // Parse SSE events from buffer + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const data = JSON.parse(line.slice(6)); + // Single-file progress + if (data.type === "single") { + if (data.phase === "complete") { + reader.cancel().catch(() => {}); + return { status: "completed", result: data.result }; + } + if (data.phase === "failed") { + reader.cancel().catch(() => {}); + return { status: "failed", error: data.error || "job failed" }; + } + } + // Batch progress + if (data.type === "batch") { + if (data.status === "completed") { + reader.cancel().catch(() => {}); + return { status: "completed" }; + } + if (data.status === "failed") { + reader.cancel().catch(() => {}); + return { + status: "failed", + error: data.errors?.map((e: { error: string }) => e.error).join("; ") || "batch failed", + }; + } + } + } catch { + // ignore parse errors + } + } + } + } finally { + reader.cancel().catch(() => {}); + } + } catch (err) { + if (Date.now() >= deadline) break; + // Connection error -- retry after a short wait + await sleep(SSE_POLL_INTERVAL_MS); + } + } + + return { status: "timeout" }; +} + +// ── Fetch output for a completed async job ──────────────────────── + +async function fetchAsyncOutput(jobId: string): Promise<{ + found: boolean; + data?: Buffer; + contentType?: string; + downloadUrl?: string; +}> { + // The download URL for async jobs follows the same pattern: + // /api/v1/download/:jobId/:filename + // But we don't know the filename. Try the files listing or guess from outputs. + // Strategy: try HEAD on a known pattern, or use the job result if available. + + // First try: list outputs directory via download with a wildcard attempt + // The container stores outputs at outputs//. Let's try to get the + // download link by hitting the files endpoint. + try { + // Try fetching the output-meta that the worker wrote + const metaRes = await fetch(`${BASE}/api/v1/download/${jobId}/output-meta.json`, { + redirect: "follow", + }); + if (metaRes.ok) { + const meta = (await metaRes.json()) as { filename?: string }; + if (meta.filename) { + const dlRes = await fetch(`${BASE}/api/v1/download/${jobId}/${meta.filename}`); + if (dlRes.ok) { + const buf = Buffer.from(await dlRes.arrayBuffer()); + return { + found: true, + data: buf, + contentType: dlRes.headers.get("content-type") || "", + downloadUrl: `/api/v1/download/${jobId}/${meta.filename}`, + }; + } + } + } + } catch { + // fall through + } + + // Fallback: try common output filenames + const commonNames = [ + "output.mp4", "output.webm", "output.mkv", "output.avi", + "output.mp3", "output.wav", "output.ogg", + "output.png", "output.jpg", "output.webp", + "output.pdf", "output.txt", "output.json", + "output.zip", + ]; + + for (const name of commonNames) { + try { + const res = await fetch(`${BASE}/api/v1/download/${jobId}/${name}`); + if (res.ok) { + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.length > 0) { + return { + found: true, + data: buf, + contentType: res.headers.get("content-type") || "", + downloadUrl: `/api/v1/download/${jobId}/${name}`, + }; + } + } + } catch { + continue; + } + } + + return { found: false }; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +// ── Representative format for AI tools ──────────────────────────── + +function aiRepresentativeFormat(tool: ToolMeta): string { + if (tool.modality === "image") return ".png"; + if (tool.modality === "video") return ".mp4"; + if (tool.modality === "audio") return ".mp3"; + if (tool.modality === "document") return ".pdf"; + if (tool.modality === "file") return ".csv"; + // Fallback: first accepted input + return tool.acceptedInputs[0] || ".png"; +} + +// ── Main sweep ──────────────────────────────────────────────────── + +async function main() { + console.log("=== SnapOtter Container API Processing Sweep ===\n"); + + // Verify container is up + try { + const health = await fetch(`${BASE}/api/v1/health`); + if (!health.ok) throw new Error(`health check returned ${health.status}`); + console.log("Container health: OK\n"); + } catch (err) { + console.error("ERROR: Cannot reach container at", BASE); + process.exit(1); + } + + const results: SweepResult[] = []; + const bugs: SweepResult[] = []; + const suspicious: SweepResult[] = []; + let totalCombos = 0; + let passes = 0; + let expectedRejects = 0; + let skipped = 0; + let bugCount = 0; + let suspiciousCount = 0; + let needsReview = 0; + + const startTime = Date.now(); + + for (const tool of tools) { + const formats = tool.isAI + ? [aiRepresentativeFormat(tool)] + : [...tool.acceptedInputs]; // clone to avoid mutation + + // Deduplicate aliases (e.g. .jpg and .jpeg resolve to same fixture) + const seenFixtures = new Set(); + + for (const ext of formats) { + totalCombos++; + const fixture = resolveFixture(ext, tool.modality); + + if (!fixture) { + const r: SweepResult = { + tool: tool.id, + format: ext, + status: "no-fixture", + outputOk: null, + note: "skipped: no fixture file for this extension", + }; + results.push(r); + skipped++; + console.log(` [SKIP] ${tool.id} x ${ext}: no fixture`); + continue; + } + + // Deduplicate: if this fixture was already tested for this tool, skip + if (seenFixtures.has(fixture)) { + const r: SweepResult = { + tool: tool.id, + format: ext, + status: "deduped-alias", + outputOk: null, + note: `skipped: alias for already-tested fixture`, + }; + results.push(r); + skipped++; + continue; + } + seenFixtures.add(fixture); + + const timeoutMs = tool.isAI ? AI_TIMEOUT_MS : (tool.executionHint === "long" ? LONG_TIMEOUT_MS : FAST_TIMEOUT_MS); + const settings = defaultSettingsFor(tool.id); + const filename = fixture.split("/").pop()!; + + console.log(` [TEST] ${tool.id} x ${ext} (${filename})...`); + + try { + const form = new FormData(); + const fileBytes = readFileSync(fixture); + form.append("file", new Blob([fileBytes]), filename); + form.append("settings", JSON.stringify(settings)); + + const controller = new AbortController(); + const fetchTimer = setTimeout(() => controller.abort(), timeoutMs); + + let res: Response; + try { + res = await fetch(`${BASE}/api/v1/tools/${tool.id}`, { + method: "POST", + body: form, + signal: controller.signal, + }); + } finally { + clearTimeout(fetchTimer); + } + + const statusCode = res.status; + const resContentType = (res.headers.get("content-type") || "").split(";")[0].trim(); + + // ── 404: custom-route tool not on standard path ────── + if (statusCode === 404) { + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 404, + outputOk: null, + note: "skipped-custom-route: tool not on standard /api/v1/tools/ path", + }; + results.push(r); + skipped++; + console.log(` [SKIP] 404 -- custom route`); + continue; + } + + // ── 4xx: legitimate rejection ──────────────────────── + if (statusCode >= 400 && statusCode < 500) { + let body = ""; + try { body = await res.text(); } catch {} + let parsed: { error?: string; details?: string } = {}; + try { parsed = JSON.parse(body); } catch {} + const msg = parsed.error || parsed.details || body.slice(0, 200); + + // Check if this format is in the tool's own acceptedInputs + const isSelfFormat = tool.acceptedInputs.includes(ext); + const classification = isSelfFormat ? "suspicious-reject" : "expected-reject"; + + const r: SweepResult = { + tool: tool.id, + format: ext, + status: statusCode, + outputOk: false, + note: `${classification}: ${statusCode} ${msg}`, + }; + results.push(r); + + if (isSelfFormat) { + suspicious.push(r); + suspiciousCount++; + console.log(` [SUSPICIOUS] ${statusCode}: ${msg}`); + } else { + expectedRejects++; + console.log(` [REJECT] ${statusCode}: ${msg.slice(0, 80)}`); + } + continue; + } + + // ── 5xx: server error = BUG ────────────────────────── + if (statusCode >= 500) { + let body = ""; + try { body = await res.text(); } catch {} + const r: SweepResult = { + tool: tool.id, + format: ext, + status: statusCode, + outputOk: false, + note: `BUG: server error ${statusCode} -- ${body.slice(0, 300)}`, + }; + results.push(r); + bugs.push(r); + bugCount++; + console.log(` [BUG] ${statusCode}: ${body.slice(0, 100)}`); + continue; + } + + // ── 200: streaming ZIP ─────────────────────────────── + if (statusCode === 200 && resContentType === "application/zip") { + const buf = Buffer.from(await res.arrayBuffer()); + const isZip = buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b; + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 200, + outputOk: isZip && buf.length > 2, + note: isZip ? `pass: ZIP stream (${buf.length} bytes)` : "BUG: ZIP content-type but invalid header", + }; + results.push(r); + if (isZip && buf.length > 2) { + passes++; + console.log(` [PASS] ZIP stream (${buf.length} bytes)`); + } else { + bugs.push(r); + bugCount++; + console.log(` [BUG] invalid ZIP stream`); + } + continue; + } + + // ── 200: JSON response (sync tool success) ─────────── + if (statusCode === 200 && resContentType === "application/json") { + let json: Record; + try { + json = await res.json() as Record; + } catch (e) { + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 200, + outputOk: false, + note: "BUG: 200 JSON but response unparseable", + }; + results.push(r); + bugs.push(r); + bugCount++; + console.log(` [BUG] unparseable JSON response`); + continue; + } + + const downloadUrl = json.downloadUrl as string | undefined; + + // Some tools return JSON results directly (info, color-palette, barcode-read, image-to-base64, etc.) + if (!downloadUrl) { + // Check if it's a result-only response (no downloadUrl but has data) + if (Object.keys(json).length > 0 && json.jobId) { + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 200, + outputOk: true, + note: `pass: JSON result (no download, keys: ${Object.keys(json).join(",")})`, + }; + results.push(r); + passes++; + console.log(` [PASS] JSON result (${Object.keys(json).join(",")})`); + continue; + } + // Truly missing downloadUrl + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 200, + outputOk: false, + note: `BUG: 200 JSON but no downloadUrl and no result data -- keys: ${Object.keys(json).join(",")}`, + }; + results.push(r); + bugs.push(r); + bugCount++; + console.log(` [BUG] no downloadUrl in response`); + continue; + } + + // Fetch the output + try { + const dlRes = await fetch(`${BASE}${downloadUrl}`); + if (!dlRes.ok) { + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 200, + outputOk: false, + note: `BUG: downloadUrl returned ${dlRes.status}`, + }; + results.push(r); + bugs.push(r); + bugCount++; + console.log(` [BUG] download failed: ${dlRes.status}`); + continue; + } + + const outBuf = Buffer.from(await dlRes.arrayBuffer()); + const outCT = dlRes.headers.get("content-type") || ""; + const verification = verifyOutput(outBuf, outCT); + + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 200, + outputOk: verification.ok, + note: verification.ok + ? `pass: ${verification.detail}` + : `BUG: corrupt success -- ${verification.detail}`, + }; + results.push(r); + + if (verification.ok) { + passes++; + console.log(` [PASS] ${verification.detail}`); + } else { + bugs.push(r); + bugCount++; + console.log(` [BUG] corrupt output: ${verification.detail}`); + } + } catch (err) { + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 200, + outputOk: false, + note: `BUG: download fetch error -- ${err instanceof Error ? err.message : String(err)}`, + }; + results.push(r); + bugs.push(r); + bugCount++; + console.log(` [BUG] download error: ${err instanceof Error ? err.message : err}`); + } + continue; + } + + // ── 200: non-JSON, non-ZIP direct binary response ──── + if (statusCode === 200) { + const buf = Buffer.from(await res.arrayBuffer()); + const verification = verifyOutput(buf, resContentType); + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 200, + outputOk: verification.ok, + note: verification.ok + ? `pass: direct binary -- ${verification.detail}` + : `BUG: direct binary corrupt -- ${verification.detail}`, + }; + results.push(r); + if (verification.ok) { + passes++; + console.log(` [PASS] direct binary: ${verification.detail}`); + } else { + bugs.push(r); + bugCount++; + console.log(` [BUG] corrupt direct output: ${verification.detail}`); + } + continue; + } + + // ── 202: async job ─────────────────────────────────── + if (statusCode === 202) { + let json: { jobId?: string; async?: boolean } = {}; + try { json = await res.json() as typeof json; } catch {} + + const jobId = json.jobId; + if (!jobId) { + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 202, + outputOk: false, + note: "BUG: 202 but no jobId in response", + }; + results.push(r); + bugs.push(r); + bugCount++; + console.log(` [BUG] 202 without jobId`); + continue; + } + + console.log(` [ASYNC] jobId=${jobId}, polling SSE...`); + const jobResult = await pollJobSSE(jobId, timeoutMs); + + if (jobResult.status === "timeout") { + const r: SweepResult = { + tool: tool.id, + format: ext, + status: "202-timeout", + outputOk: false, + note: `BUG: async job timed out after ${timeoutMs}ms`, + }; + results.push(r); + bugs.push(r); + bugCount++; + console.log(` [BUG] async timeout`); + continue; + } + + if (jobResult.status === "failed") { + const r: SweepResult = { + tool: tool.id, + format: ext, + status: "202-failed", + outputOk: false, + note: `BUG: async job failed -- ${jobResult.error || "unknown"}`, + }; + results.push(r); + bugs.push(r); + bugCount++; + console.log(` [BUG] async failed: ${jobResult.error}`); + continue; + } + + // Job completed -- try to fetch the output + // The SSE result might contain the downloadUrl in result payload + if (jobResult.result && (jobResult.result as Record).downloadUrl) { + const dlUrl = (jobResult.result as Record).downloadUrl as string; + try { + const dlRes = await fetch(`${BASE}${dlUrl}`); + if (dlRes.ok) { + const outBuf = Buffer.from(await dlRes.arrayBuffer()); + const outCT = dlRes.headers.get("content-type") || ""; + const verification = verifyOutput(outBuf, outCT); + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 202, + outputOk: verification.ok, + note: verification.ok + ? `pass: async completed -- ${verification.detail}` + : `BUG: async completed but corrupt -- ${verification.detail}`, + }; + results.push(r); + if (verification.ok) { + passes++; + console.log(` [PASS] async: ${verification.detail}`); + } else { + bugs.push(r); + bugCount++; + console.log(` [BUG] async corrupt: ${verification.detail}`); + } + continue; + } + } catch { + // Fall through to generic fetch + } + } + + // Try to fetch the output using the async output fetcher + const asyncOut = await fetchAsyncOutput(jobId); + if (asyncOut.found && asyncOut.data) { + const verification = verifyOutput(asyncOut.data, asyncOut.contentType || ""); + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 202, + outputOk: verification.ok, + note: verification.ok + ? `pass: async completed -- ${verification.detail}` + : `BUG: async completed but corrupt -- ${verification.detail}`, + }; + results.push(r); + if (verification.ok) { + passes++; + console.log(` [PASS] async: ${verification.detail}`); + } else { + bugs.push(r); + bugCount++; + console.log(` [BUG] async corrupt: ${verification.detail}`); + } + } else { + // Could not find the output -- record as needs-review + const r: SweepResult = { + tool: tool.id, + format: ext, + status: 202, + outputOk: null, + note: "needs-review: async job completed per SSE but output not retrievable", + }; + results.push(r); + needsReview++; + console.log(` [NEEDS-REVIEW] async completed but output not found`); + } + continue; + } + + // ── Unexpected status code ─────────────────────────── + let body = ""; + try { body = await res.text(); } catch {} + const r: SweepResult = { + tool: tool.id, + format: ext, + status: statusCode, + outputOk: false, + note: `BUG: unexpected status ${statusCode} -- ${body.slice(0, 200)}`, + }; + results.push(r); + bugs.push(r); + bugCount++; + console.log(` [BUG] unexpected status ${statusCode}`); + + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const isTimeout = msg.includes("abort") || msg.includes("timeout"); + const r: SweepResult = { + tool: tool.id, + format: ext, + status: isTimeout ? "timeout" : "network-error", + outputOk: false, + note: `BUG: ${isTimeout ? "request timed out" : "network error"} -- ${msg.slice(0, 200)}`, + }; + results.push(r); + bugs.push(r); + bugCount++; + console.log(` [BUG] ${isTimeout ? "timeout" : "network error"}: ${msg.slice(0, 80)}`); + } + } + } + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + // ── Write results ───────────────────────────────────────────── + + mkdirSync(OUT_DIR, { recursive: true }); + + writeFileSync( + join(OUT_DIR, "api-sweep-results.json"), + JSON.stringify(results, null, 2), + ); + + // ── Write findings markdown ─────────────────────────────────── + + let md = "# SnapOtter Container API Sweep Findings\n\n"; + md += `**Date**: ${new Date().toISOString().split("T")[0]}\n`; + md += `**Container**: snapotter-qa at ${BASE}\n`; + md += `**Elapsed**: ${elapsed}s\n\n`; + md += "## Summary\n\n"; + md += `| Metric | Count |\n`; + md += `|--------|-------|\n`; + md += `| Total combos tested | ${totalCombos} |\n`; + md += `| Clean passes | ${passes} |\n`; + md += `| Expected rejects | ${expectedRejects} |\n`; + md += `| Skipped (no fixture / alias / custom route) | ${skipped} |\n`; + md += `| Suspicious self-rejects | ${suspiciousCount} |\n`; + md += `| Needs review | ${needsReview} |\n`; + md += `| **BUGS** | **${bugCount}** |\n\n`; + + if (bugs.length > 0) { + md += "## Bugs\n\n"; + + // Group by tool + const byTool = new Map(); + for (const b of bugs) { + if (!byTool.has(b.tool)) byTool.set(b.tool, []); + byTool.get(b.tool)!.push(b); + } + + for (const [toolId, toolBugs] of byTool) { + md += `### ${toolId}\n\n`; + for (const b of toolBugs) { + md += `- **${b.format}** (status ${b.status}): ${b.note}\n`; + } + md += "\n"; + } + } + + if (suspicious.length > 0) { + md += "## Suspicious Self-Rejects\n\n"; + md += "These tools rejected a format that IS listed in their own acceptedInputs:\n\n"; + + const byTool = new Map(); + for (const s of suspicious) { + if (!byTool.has(s.tool)) byTool.set(s.tool, []); + byTool.get(s.tool)!.push(s); + } + + for (const [toolId, toolSuspicious] of byTool) { + md += `### ${toolId}\n\n`; + for (const s of toolSuspicious) { + md += `- **${s.format}** (status ${s.status}): ${s.note}\n`; + } + md += "\n"; + } + } + + writeFileSync(join(OUT_DIR, "findings-api.md"), md); + + // ── Console summary ─────────────────────────────────────────── + + console.log("\n" + "=".repeat(60)); + console.log("SWEEP COMPLETE"); + console.log("=".repeat(60)); + console.log(`Total combos: ${totalCombos}`); + console.log(`Passes: ${passes}`); + console.log(`Expected rejects: ${expectedRejects}`); + console.log(`Skipped: ${skipped}`); + console.log(`Suspicious: ${suspiciousCount}`); + console.log(`Needs review: ${needsReview}`); + console.log(`BUGS: ${bugCount}`); + console.log(`Elapsed: ${elapsed}s`); + console.log(`\nResults: ${join(OUT_DIR, "api-sweep-results.json")}`); + console.log(`Findings: ${join(OUT_DIR, "findings-api.md")}`); + + if (bugs.length > 0) { + console.log("\n--- TOP BUGS ---"); + for (const b of bugs.slice(0, 20)) { + console.log(` ${b.tool} x ${b.format}: ${b.note.slice(0, 120)}`); + } + } + + process.exit(bugCount > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error("FATAL:", err); + process.exit(2); +}); diff --git a/tests/qa/crosscutting.qa.spec.ts b/tests/qa/crosscutting.qa.spec.ts new file mode 100644 index 00000000..a0bf161b --- /dev/null +++ b/tests/qa/crosscutting.qa.spec.ts @@ -0,0 +1,495 @@ +import { expect, test } from "@playwright/test"; +import { instrument, isClean, issuesSummary, toolPath } from "./qa-helpers"; + +// Cross-cutting UI quality sweep: locale, a11y, responsive, dark mode. +// Designed for the QA container at localhost:13499 (auth off). +// Run with --workers=2 to avoid overloading the host. + +// --------------------------------------------------------------------------- +// Shared constants +// --------------------------------------------------------------------------- +const ALL_LOCALES = [ + { code: "en", dir: "ltr" }, + { code: "ar", dir: "rtl" }, + { code: "de", dir: "ltr" }, + { code: "es", dir: "ltr" }, + { code: "fr", dir: "ltr" }, + { code: "hi", dir: "ltr" }, + { code: "id", dir: "ltr" }, + { code: "it", dir: "ltr" }, + { code: "ja", dir: "ltr" }, + { code: "ko", dir: "ltr" }, + { code: "nl", dir: "ltr" }, + { code: "pl", dir: "ltr" }, + { code: "pt-BR", dir: "ltr" }, + { code: "ru", dir: "ltr" }, + { code: "sv", dir: "ltr" }, + { code: "th", dir: "ltr" }, + { code: "tr", dir: "ltr" }, + { code: "uk", dir: "ltr" }, + { code: "vi", dir: "ltr" }, + { code: "zh-CN", dir: "ltr" }, + { code: "zh-TW", dir: "ltr" }, +] as const; + +const RESIZE_PATH = toolPath("resize"); +const CONVERT_AUDIO_PATH = toolPath("convert-audio"); + +// Patterns that indicate a raw i18n key leaked into visible text +const I18N_KEY_PATTERN = + /(?:^|\s)(tools\.\w+\.\w+|common\.\w+|settings\.\w+|categories\.\w+|editor\.\w+|nav\.\w+|errors?\.\w+|auth\.\w+|home\.\w+|general\.\w+|upload\.\w+|dropzone\.\w+)\b/; + +/** Set locale via localStorage + reload, matching the app's i18n mechanism. */ +async function setLocale(page: import("@playwright/test").Page, code: string) { + await page.evaluate((c) => localStorage.setItem("snapotter-locale", c), code); + await page.reload({ waitUntil: "domcontentloaded" }); + // Give the async locale loader time to apply translations + await page.waitForTimeout(1200); +} + +/** Click the "Toggle theme" button in the top nav. The button has title="Toggle theme". */ +async function clickThemeToggle(page: import("@playwright/test").Page) { + const toggle = page.locator('button[title="Toggle theme"]'); + await toggle.waitFor({ state: "visible", timeout: 5000 }); + await toggle.click(); + await page.waitForTimeout(600); +} + +// ========================================================================= +// 1) LOCALE +// ========================================================================= +test.describe("Locale", () => { + for (const locale of ALL_LOCALES) { + test(`${locale.code}: home + tools render without i18n key leaks or layout breaks`, async ({ + page, + }) => { + const issues = instrument(page); + + // Navigate to home and set locale + await page.goto("/", { waitUntil: "domcontentloaded" }); + await setLocale(page, locale.code); + + // (a) Check lang attribute + const lang = await page.getAttribute("html", "lang"); + expect(lang, `html lang should be ${locale.code}`).toBe(locale.code); + + // (b) Check dir attribute for RTL + if (locale.dir === "rtl") { + const dir = await page.getAttribute("html", "dir"); + expect(dir, `html dir should be rtl for ${locale.code}`).toBe("rtl"); + } + + // (c) No raw i18n keys in home page body + const homeBody = await page.evaluate(() => document.body.innerText); + const homeKeys = homeBody.match(new RegExp(I18N_KEY_PATTERN.source, "gm")) || []; + expect( + homeKeys.length, + `Home (${locale.code}): raw i18n keys found: ${homeKeys.join(", ")}`, + ).toBe(0); + + // (d) No horizontal overflow on home + const homeOverflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect( + homeOverflow, + `Home (${locale.code}): horizontal overflow of ${homeOverflow}px`, + ).toBeLessThanOrEqual(4); + + // Navigate to /image/resize + await page.goto(RESIZE_PATH, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(800); + + const resizeBody = await page.evaluate(() => document.body.innerText); + const resizeKeys = resizeBody.match(new RegExp(I18N_KEY_PATTERN.source, "gm")) || []; + expect( + resizeKeys.length, + `Resize (${locale.code}): raw i18n keys found: ${resizeKeys.join(", ")}`, + ).toBe(0); + + // Navigate to /audio/convert-audio + await page.goto(CONVERT_AUDIO_PATH, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(800); + + const audioBody = await page.evaluate(() => document.body.innerText); + const audioKeys = audioBody.match(new RegExp(I18N_KEY_PATTERN.source, "gm")) || []; + expect( + audioKeys.length, + `ConvertAudio (${locale.code}): raw i18n keys found: ${audioKeys.join(", ")}`, + ).toBe(0); + + // Console should be clean (no missing-translation warnings, no errors) + expect(isClean(issues), `${locale.code} console issues:\n${issuesSummary(issues)}`).toBe( + true, + ); + }); + } +}); + +// ========================================================================= +// 2) A11Y (Playwright built-ins only, no axe) +// ========================================================================= +test.describe("A11y", () => { + const PAGES = [ + { name: "Home", path: "/" }, + { name: "Resize", path: RESIZE_PATH }, + ]; + + for (const pg of PAGES) { + test(`${pg.name}: h1, landmarks, accessible names, alt text`, async ({ page }) => { + const issues = instrument(page); + await page.goto(pg.path, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(600); + + // h1: tool pages must have exactly 1; home (catalog) may have 0 + const h1Count = await page.locator("h1").count(); + if (pg.path !== "/") { + expect(h1Count, `${pg.name}: expected exactly 1

, found ${h1Count}`).toBe(1); + } + // If home has 0 h1, we record it in findings but don't fail (catalog page) + + // Landmark:
present + const mainCount = await page.locator("main, [role='main']").count(); + expect(mainCount, `${pg.name}: no
landmark`).toBeGreaterThanOrEqual(1); + + // Landmark: