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/.
This commit is contained in:
SnapOtter
2026-06-15 22:26:24 +08:00
committed by GitHub
parent 25babfae15
commit d8cf979d4b
83 changed files with 16014 additions and 112 deletions
@@ -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<string, unknown> = { 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}
</button>
)}
@@ -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}
</button>
)}
</div>
@@ -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<string | null>(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 (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-4">
<div className="mx-auto w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-2">
<FileText className="h-8 w-8 text-muted-foreground" />
</div>
{ext && <p className="text-xs text-muted-foreground">{ext}</p>}
<p className="text-sm text-muted-foreground text-center">
{t.tools.documentView.inputNotPreviewable}
</p>
</div>
);
}
return (
<div className="flex h-full w-full flex-col items-center gap-2 overflow-auto p-4">
{error && <p className="p-4 text-sm text-destructive">{t.tools.documentView.loadFailed}</p>}
@@ -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 <video>/<audio> playback over the Range-capable download endpoint
* (spec 4.6). Shows the processed result when present, else the source file.
* Falls back to NonNativePreview (server transcode) when the browser cannot
* decode the codec (e.g. Theora in .ogv -- videoWidth is 0).
*/
export function MediaPlayerView() {
const { t } = useTranslation();
const entry = useFileStore((s) => s.entries[s.selectedIndex]);
const videoRef = useRef<HTMLVideoElement>(null);
const [unsupportedCodec, setUnsupportedCodec] = useState(false);
if (!entry) return null;
const src = entry.processedUrl ?? entry.blobUrl;
const isAudio = entry.modality === "audio";
// F7: if the browser loaded the container but cannot decode the codec,
// videoWidth will be 0. Fall back to the server-transcode preview.
if (!isAudio && unsupportedCodec) {
return (
<div className="flex h-full w-full items-center justify-center p-4">
<NonNativePreview
file={entry.file}
src={src}
filename={entry.file?.name ?? "video"}
fileSize={entry.file?.size ?? 0}
modality="video"
/>
</div>
);
}
return (
<div className="flex h-full w-full items-center justify-center p-4">
{isAudio ? (
@@ -19,10 +43,16 @@ export function MediaPlayerView() {
</audio>
) : (
<video
ref={videoRef}
controls
src={src}
className="max-h-full max-w-full rounded-lg"
data-testid="media-player-video"
onLoadedMetadata={() => {
if (videoRef.current && videoRef.current.videoWidth === 0) {
setUnsupportedCodec(true);
}
}}
>
<track kind="captions" />
{t.tools.mediaPlayer.unsupported}
@@ -2,7 +2,6 @@ import { useState } from "react";
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";
type AudioFormat = "mp3" | "wav" | "flac" | "m4a";
@@ -11,21 +10,14 @@ export function MergeAudioSettings() {
const { t } = useTranslation();
const s = t.toolSettings["merge-audio"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("merge-audio");
const { processFiles, processing, error, progress } = useToolProcessor("merge-audio");
const [outFormat, setOutFormat] = useState<AudioFormat>("mp3");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const hasEnough = files.length >= 2;
const handleProcess = () => {
const settings = { format: outFormat };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
processFiles(files, { format: outFormat });
};
return (
@@ -65,10 +57,10 @@ export function MergeAudioSettings() {
type="button"
data-testid="merge-audio-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
disabled={!hasEnough || 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}
</button>
)}
</div>
@@ -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 MergeCsvsSettings() {
const { t } = useTranslation();
const s = t.toolSettings["merge-csvs"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("merge-csvs");
const { processFiles, processing, error, progress } = useToolProcessor("merge-csvs");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const hasEnough = files.length >= 2;
const handleProcess = () => {
const settings = {};
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
processFiles(files, {});
};
return (
@@ -41,10 +33,10 @@ export function MergeCsvsSettings() {
type="button"
data-testid="merge-csvs-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
disabled={!hasEnough || 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}
</button>
)}
</div>