diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3625fed..8d860dd 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -38,6 +38,18 @@ const app = Fastify({ bodyLimit: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024, }); +app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => { + const statusCode = error.statusCode ?? 500; + request.log.error( + { err: error, url: request.url, method: request.method }, + "Unhandled request error", + ); + reply.status(statusCode).send({ + error: statusCode >= 500 ? "Internal server error" : error.message, + details: error.stack ?? error.message, + }); +}); + // Plugins await app.register(cors, { origin: env.CORS_ORIGIN diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts new file mode 100644 index 0000000..bc8fc13 --- /dev/null +++ b/apps/api/src/lib/errors.ts @@ -0,0 +1,7 @@ +import type { ZodIssue } from "zod"; + +export function formatZodErrors(issues: ZodIssue[]): string { + return issues + .map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message)) + .join("; "); +} diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts index 8e9d8c4..9d30fea 100644 --- a/apps/api/src/routes/batch.ts +++ b/apps/api/src/routes/batch.ts @@ -13,6 +13,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import PQueue from "p-queue"; import { env } from "../config.js"; import { autoOrient } from "../lib/auto-orient.js"; +import { formatZodErrors } from "../lib/errors.js"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; import { decodeHeic } from "../lib/heic-converter.js"; @@ -88,12 +89,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise { if (!result.success) { return reply.status(400).send({ error: "Invalid settings", - details: result.error.issues.map( - (i: { path: (string | number)[]; message: string }) => ({ - path: i.path.join("."), - message: i.message, - }), - ), + details: formatZodErrors(result.error.issues), }); } settings = result.data; diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index 9241a02..f2901cf 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -17,6 +17,7 @@ import { z } from "zod"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { autoOrient } from "../lib/auto-orient.js"; +import { formatZodErrors } from "../lib/errors.js"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; import { decodeHeic } from "../lib/heic-converter.js"; @@ -130,10 +131,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise ({ - path: i.path.join("."), - message: i.message, - })), + details: formatZodErrors(result.error.issues), }); } pipeline = result.data; @@ -431,10 +429,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise ({ - path: i.path.join("."), - message: i.message, - })), + details: formatZodErrors(result.error.issues), }); } pipeline = result.data; diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index d1ee9db..23efce3 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -7,6 +7,7 @@ import sharp from "sharp"; import type { z } from "zod"; import { db, schema } from "../db/index.js"; import { autoOrient } from "../lib/auto-orient.js"; +import { formatZodErrors } from "../lib/errors.js"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; import { decodeHeic } from "../lib/heic-converter.js"; @@ -185,10 +186,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig if (!result.success) { return reply.status(400).send({ error: "Invalid settings", - details: result.error.issues.map((i) => ({ - path: i.path.join("."), - message: i.message, - })), + details: formatZodErrors(result.error.issues), }); } settings = result.data; diff --git a/apps/api/src/routes/tools/bulk-rename.ts b/apps/api/src/routes/tools/bulk-rename.ts index 48a8b5a..fc092c3 100644 --- a/apps/api/src/routes/tools/bulk-rename.ts +++ b/apps/api/src/routes/tools/bulk-rename.ts @@ -3,6 +3,7 @@ import { basename, extname } from "node:path"; import archiver from "archiver"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; +import { formatZodErrors } from "../../lib/errors.js"; const settingsSchema = z.object({ pattern: z.string().min(1).max(200).default("image-{{index}}"), @@ -53,7 +54,9 @@ export function registerBulkRename(app: FastifyInstance) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = settingsSchema.safeParse(parsed); if (!result.success) { - return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { diff --git a/apps/api/src/routes/tools/collage.ts b/apps/api/src/routes/tools/collage.ts index cd6b432..612e03c 100644 --- a/apps/api/src/routes/tools/collage.ts +++ b/apps/api/src/routes/tools/collage.ts @@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -467,7 +468,9 @@ export function registerCollage(app: FastifyInstance) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = settingsSchema.safeParse(parsed); if (!result.success) { - return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { diff --git a/apps/api/src/routes/tools/compose.ts b/apps/api/src/routes/tools/compose.ts index 8485156..864928e 100644 --- a/apps/api/src/routes/tools/compose.ts +++ b/apps/api/src/routes/tools/compose.ts @@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -74,7 +75,9 @@ export function registerCompose(app: FastifyInstance) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = settingsSchema.safeParse(parsed); if (!result.success) { - return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { diff --git a/apps/api/src/routes/tools/content-aware-resize.ts b/apps/api/src/routes/tools/content-aware-resize.ts index a80359d..9bbcd5c 100644 --- a/apps/api/src/routes/tools/content-aware-resize.ts +++ b/apps/api/src/routes/tools/content-aware-resize.ts @@ -5,6 +5,7 @@ import { seamCarve } from "@ashim/ai"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeHeic } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -82,10 +83,7 @@ export function registerContentAwareResize(app: FastifyInstance) { if (!result.success) { return reply.status(400).send({ error: "Invalid settings", - details: result.error.issues.map((i) => ({ - path: i.path.join("."), - message: i.message, - })), + details: formatZodErrors(result.error.issues), }); } settings = result.data; diff --git a/apps/api/src/routes/tools/edit-metadata.ts b/apps/api/src/routes/tools/edit-metadata.ts index 6c41021..cf0d64b 100644 --- a/apps/api/src/routes/tools/edit-metadata.ts +++ b/apps/api/src/routes/tools/edit-metadata.ts @@ -4,6 +4,7 @@ import { basename, join } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { formatZodErrors } from "../../lib/errors.js"; import { buildTagArgs, type EditMetadataSettings, @@ -150,10 +151,7 @@ export function registerEditMetadata(app: FastifyInstance) { if (!result.success) { return reply.status(400).send({ error: "Invalid settings", - details: result.error.issues.map((i) => ({ - path: i.path.join("."), - message: i.message, - })), + details: formatZodErrors(result.error.issues), }); } settings = result.data; diff --git a/apps/api/src/routes/tools/image-to-pdf.ts b/apps/api/src/routes/tools/image-to-pdf.ts index b3bca25..c2b820f 100644 --- a/apps/api/src/routes/tools/image-to-pdf.ts +++ b/apps/api/src/routes/tools/image-to-pdf.ts @@ -6,6 +6,7 @@ import PDFDocument from "pdfkit"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -62,7 +63,9 @@ export function registerImageToPdf(app: FastifyInstance) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = settingsSchema.safeParse(parsed); if (!result.success) { - return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { diff --git a/apps/api/src/routes/tools/ocr.ts b/apps/api/src/routes/tools/ocr.ts index 56a7a94..8ce6e05 100644 --- a/apps/api/src/routes/tools/ocr.ts +++ b/apps/api/src/routes/tools/ocr.ts @@ -3,6 +3,7 @@ import { basename } from "node:path"; import { extractText } from "@ashim/ai"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; +import { formatZodErrors } from "../../lib/errors.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; @@ -66,7 +67,7 @@ export function registerOcr(app: FastifyInstance) { if (!result.success) { return reply .status(400) - .send({ error: "Invalid settings", details: result.error.issues }); + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { @@ -135,10 +136,18 @@ export function registerOcr(app: FastifyInstance) { }); } + if (result.engine && result.engine !== tier) { + request.log.warn( + { toolId: "ocr", requested: tier, actual: result.engine }, + `OCR engine fallback: requested ${tier} but used ${result.engine}`, + ); + } + return reply.send({ jobId, filename, text: result.text, + engine: result.engine, }); } catch (err) { lastError = err; diff --git a/apps/api/src/routes/tools/optimize-for-web.ts b/apps/api/src/routes/tools/optimize-for-web.ts index 6112d7e..58d2c82 100644 --- a/apps/api/src/routes/tools/optimize-for-web.ts +++ b/apps/api/src/routes/tools/optimize-for-web.ts @@ -4,6 +4,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeHeic } from "../../lib/heic-converter.js"; @@ -117,10 +118,7 @@ export function registerOptimizeForWeb(app: FastifyInstance) { if (!result.success) { return reply.status(400).send({ error: "Invalid settings", - details: result.error.issues.map((i) => ({ - path: i.path.join("."), - message: i.message, - })), + details: formatZodErrors(result.error.issues), }); } settings = result.data; diff --git a/apps/api/src/routes/tools/passport-photo.ts b/apps/api/src/routes/tools/passport-photo.ts index 179eaaa..d8e9c64 100644 --- a/apps/api/src/routes/tools/passport-photo.ts +++ b/apps/api/src/routes/tools/passport-photo.ts @@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeHeic } from "../../lib/heic-converter.js"; import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js"; @@ -275,15 +276,14 @@ export function registerPassportPhoto(app: FastifyInstance) { app.post( "/api/v1/tools/passport-photo/generate", async (request: FastifyRequest, reply: FastifyReply) => { - let parsed: z.infer; - try { - parsed = generateSettingsSchema.parse(request.body); - } catch (err) { + const parseResult = generateSettingsSchema.safeParse(request.body); + if (!parseResult.success) { return reply.status(400).send({ error: "Invalid settings", - details: err instanceof Error ? err.message : String(err), + details: formatZodErrors(parseResult.error.issues), }); } + const parsed = parseResult.data; const { jobId, diff --git a/apps/api/src/routes/tools/pdf-to-image.ts b/apps/api/src/routes/tools/pdf-to-image.ts index 9ee548d..aa80ae5 100644 --- a/apps/api/src/routes/tools/pdf-to-image.ts +++ b/apps/api/src/routes/tools/pdf-to-image.ts @@ -7,6 +7,7 @@ import type { FastifyInstance } from "fastify"; import * as mupdf from "mupdf"; import sharp from "sharp"; import { z } from "zod"; +import { formatZodErrors } from "../../lib/errors.js"; import { encodeHeic } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -290,7 +291,9 @@ export function registerPdfToImage(app: FastifyInstance) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = settingsSchema.safeParse(parsed); if (!result.success) { - return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { diff --git a/apps/api/src/routes/tools/qr-generate.ts b/apps/api/src/routes/tools/qr-generate.ts index 7077668..b42245a 100644 --- a/apps/api/src/routes/tools/qr-generate.ts +++ b/apps/api/src/routes/tools/qr-generate.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import QRCode from "qrcode"; import { z } from "zod"; +import { formatZodErrors } from "../../lib/errors.js"; import { createWorkspace } from "../../lib/workspace.js"; const settingsSchema = z.object({ @@ -37,10 +38,7 @@ export function registerQrGenerate(app: FastifyInstance) { if (!result.success) { return reply.status(400).send({ error: "Invalid settings", - details: result.error.issues.map((i) => ({ - path: i.path.join("."), - message: i.message, - })), + details: formatZodErrors(result.error.issues), }); } diff --git a/apps/api/src/routes/tools/split.ts b/apps/api/src/routes/tools/split.ts index e99a638..e76fa40 100644 --- a/apps/api/src/routes/tools/split.ts +++ b/apps/api/src/routes/tools/split.ts @@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; const settingsSchema = z.object({ @@ -67,7 +68,9 @@ export function registerSplit(app: FastifyInstance) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = settingsSchema.safeParse(parsed); if (!result.success) { - return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { diff --git a/apps/api/src/routes/tools/stitch.ts b/apps/api/src/routes/tools/stitch.ts index 025bec5..788ccab 100644 --- a/apps/api/src/routes/tools/stitch.ts +++ b/apps/api/src/routes/tools/stitch.ts @@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -91,7 +92,9 @@ export function registerStitch(app: FastifyInstance) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = settingsSchema.safeParse(parsed); if (!result.success) { - return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { diff --git a/apps/api/src/routes/tools/svg-to-raster.ts b/apps/api/src/routes/tools/svg-to-raster.ts index 684e16d..c4ae0cd 100644 --- a/apps/api/src/routes/tools/svg-to-raster.ts +++ b/apps/api/src/routes/tools/svg-to-raster.ts @@ -7,6 +7,7 @@ import PQueue from "p-queue"; import sharp from "sharp"; import { z } from "zod"; import { env } from "../../config.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js"; import { isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js"; @@ -147,7 +148,7 @@ export function registerSvgToRaster(app: FastifyInstance) { if (!result.success) { return reply.status(400).send({ error: "Invalid settings", - details: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })), + details: formatZodErrors(result.error.issues), }); } settings = result.data; @@ -365,7 +366,9 @@ export function registerSvgToRaster(app: FastifyInstance) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = settingsSchema.safeParse(parsed); if (!result.success) { - return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { diff --git a/apps/api/src/routes/tools/vectorize.ts b/apps/api/src/routes/tools/vectorize.ts index 4a69ea4..581344b 100644 --- a/apps/api/src/routes/tools/vectorize.ts +++ b/apps/api/src/routes/tools/vectorize.ts @@ -7,6 +7,7 @@ import potrace from "potrace"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -82,7 +83,9 @@ export function registerVectorize(app: FastifyInstance) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = settingsSchema.safeParse(parsed); if (!result.success) { - return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { diff --git a/apps/api/src/routes/tools/watermark-image.ts b/apps/api/src/routes/tools/watermark-image.ts index c645782..5864841 100644 --- a/apps/api/src/routes/tools/watermark-image.ts +++ b/apps/api/src/routes/tools/watermark-image.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; const settingsSchema = z.object({ @@ -56,7 +57,9 @@ export function registerWatermarkImage(app: FastifyInstance) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; const result = settingsSchema.safeParse(parsed); if (!result.success) { - return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } settings = result.data; } catch { diff --git a/apps/web/src/components/tools/blur-faces-settings.tsx b/apps/web/src/components/tools/blur-faces-settings.tsx index 5fad33b..aeb534f 100644 --- a/apps/web/src/components/tools/blur-faces-settings.tsx +++ b/apps/web/src/components/tools/blur-faces-settings.tsx @@ -84,12 +84,24 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF export function BlurFacesSettings() { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = - useToolProcessor("blur-faces"); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + originalSize, + processedSize, + progress, + } = useToolProcessor("blur-faces"); const [settings, setSettings] = useState>({}); const handleProcess = () => { - processFiles(files, settings); + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; const hasFile = files.length > 0; diff --git a/apps/web/src/components/tools/edit-metadata-settings.tsx b/apps/web/src/components/tools/edit-metadata-settings.tsx index 0a3ac49..24aa6c9 100644 --- a/apps/web/src/components/tools/edit-metadata-settings.tsx +++ b/apps/web/src/components/tools/edit-metadata-settings.tsx @@ -133,8 +133,16 @@ function LabeledInput({ export function EditMetadataSettings() { const { entries, selectedIndex, files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = - useToolProcessor("edit-metadata"); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + originalSize, + processedSize, + progress, + } = useToolProcessor("edit-metadata"); const [form, setForm] = useState(EMPTY_FORM); const [initialForm, setInitialForm] = useState(EMPTY_FORM); @@ -391,7 +399,11 @@ export function EditMetadataSettings() { settings.fieldsToRemove = Array.from(fieldsToRemove); } - processFiles(files, settings); + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; return ( diff --git a/apps/web/src/components/tools/erase-object-settings.tsx b/apps/web/src/components/tools/erase-object-settings.tsx index 627a8da..ab05c0c 100644 --- a/apps/web/src/components/tools/erase-object-settings.tsx +++ b/apps/web/src/components/tools/erase-object-settings.tsx @@ -111,7 +111,13 @@ export function EraseObjectSettings({ } else { try { const body = JSON.parse(xhr.responseText); - setError(body.error || body.details || `Failed: ${xhr.status}`); + setError( + typeof body.error === "string" + ? body.error + : typeof body.details === "string" + ? body.details + : `Failed: ${xhr.status}`, + ); } catch { setError(`Processing failed: ${xhr.status}`); } diff --git a/apps/web/src/components/tools/image-to-pdf-settings.tsx b/apps/web/src/components/tools/image-to-pdf-settings.tsx index 8e638c8..82c4842 100644 --- a/apps/web/src/components/tools/image-to-pdf-settings.tsx +++ b/apps/web/src/components/tools/image-to-pdf-settings.tsx @@ -220,10 +220,9 @@ export function ImageToPdfSettings() { }; xhr.open("POST", "/api/v1/tools/image-to-pdf"); - const headers = formatHeaders(); - for (const [key, value] of Object.entries(headers)) { - xhr.setRequestHeader(key, value as string); - } + formatHeaders().forEach((value, key) => { + xhr.setRequestHeader(key, value); + }); xhr.send(formData); }, [files, pageSize, orientation, margin, setProcessing, setError]); diff --git a/apps/web/src/components/tools/passport-photo-settings.tsx b/apps/web/src/components/tools/passport-photo-settings.tsx index 7098bc7..893bc1c 100644 --- a/apps/web/src/components/tools/passport-photo-settings.tsx +++ b/apps/web/src/components/tools/passport-photo-settings.tsx @@ -442,7 +442,14 @@ export function PassportPhotoSettings() { if (!response.ok) { const body = await response.json().catch(() => null); - throw new Error(body?.details || body?.error || `Analysis failed: ${response.status}`); + const msg = body + ? typeof body.details === "string" + ? body.details + : typeof body.error === "string" + ? body.error + : `Analysis failed: ${response.status}` + : `Analysis failed: ${response.status}`; + throw new Error(msg); } const result = await response.json(); @@ -504,9 +511,14 @@ export function PassportPhotoSettings() { if (!response.ok) { const errBody = await response.json().catch(() => null); - throw new Error( - errBody?.details || errBody?.error || `Generation failed: ${response.status}`, - ); + const msg = errBody + ? typeof errBody.details === "string" + ? errBody.details + : typeof errBody.error === "string" + ? errBody.error + : `Generation failed: ${response.status}` + : `Generation failed: ${response.status}`; + throw new Error(msg); } const result: GenerateResult = await response.json(); diff --git a/apps/web/src/components/tools/split-settings.tsx b/apps/web/src/components/tools/split-settings.tsx index 8a41011..2151ebf 100644 --- a/apps/web/src/components/tools/split-settings.tsx +++ b/apps/web/src/components/tools/split-settings.tsx @@ -92,8 +92,6 @@ export function SplitSettings() { setZipBlobUrl(null); try { - const formData = new FormData(); - formData.append("file", files[0]); const effectiveGrid = getEffectiveGrid(); const settings: Record = { columns: effectiveGrid.columns, @@ -107,41 +105,60 @@ export function SplitSettings() { if (LOSSY_FORMATS.has(outputFormat)) { settings.quality = quality; } - formData.append("settings", JSON.stringify(settings)); - - const res = await fetch("/api/v1/tools/split", { - method: "POST", - headers: formatHeaders(), - body: formData, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `Failed: ${res.status}`); - } - - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - setZipBlobUrl(url); + const settingsJson = JSON.stringify(settings); const JSZip = (await import("jszip")).default; - const zip = await JSZip.loadAsync(blob); - const tileEntries: Array<{ row: number; col: number; blobUrl: string | null }> = []; + const combinedZip = new JSZip(); + const previewTiles: Array<{ row: number; col: number; blobUrl: string | null }> = []; + const multiFile = files.length > 1; - const fileNames = Object.keys(zip.files).filter((n) => !zip.files[n].dir); - fileNames.sort(); + for (let fi = 0; fi < files.length; fi++) { + const file = files[fi]; + const formData = new FormData(); + formData.append("file", file); + formData.append("settings", settingsJson); - for (const name of fileNames) { - const fileData = await zip.files[name].async("blob"); - const tileBlobUrl = URL.createObjectURL(fileData); - const match = name.match(/_r(\d+)_c(\d+)/); - const row = match ? Number.parseInt(match[1], 10) : 0; - const col = match ? Number.parseInt(match[2], 10) : 0; - tileEntries.push({ row, col, blobUrl: tileBlobUrl }); + const res = await fetch("/api/v1/tools/split", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to split ${file.name}: ${text || res.status}`); + } + + const blob = await res.blob(); + const fileZip = await JSZip.loadAsync(blob); + const baseName = file.name.replace(/\.[^.]+$/, ""); + const prefix = multiFile ? `${baseName}/` : ""; + + const fileNames = Object.keys(fileZip.files).filter((n) => !fileZip.files[n].dir); + fileNames.sort(); + + for (const name of fileNames) { + const data = await fileZip.files[name].async("uint8array"); + combinedZip.file(`${prefix}${name}`, data); + + if (fi === 0) { + const tileBlob = new Blob([data as BlobPart]); + const tileBlobUrl = URL.createObjectURL(tileBlob); + const match = name.match(/_r(\d+)_c(\d+)/); + previewTiles.push({ + row: match ? Number.parseInt(match[1], 10) : 0, + col: match ? Number.parseInt(match[2], 10) : 0, + blobUrl: tileBlobUrl, + }); + } + } } - tileEntries.sort((a, b) => a.row - b.row || a.col - b.col); + const combinedBlob = await combinedZip.generateAsync({ type: "blob" }); + setZipBlobUrl(URL.createObjectURL(combinedBlob)); + + previewTiles.sort((a, b) => a.row - b.row || a.col - b.col); setTiles( - tileEntries.map((t, i) => ({ + previewTiles.map((t, i) => ({ row: t.row, col: t.col, label: `${i + 1}`, @@ -173,7 +190,8 @@ export function SplitSettings() { if (!zipBlobUrl) return; const a = document.createElement("a"); a.href = zipBlobUrl; - const baseName = files[0]?.name?.replace(/\.[^.]+$/, "") ?? "split"; + const baseName = + files.length > 1 ? "split-batch" : (files[0]?.name?.replace(/\.[^.]+$/, "") ?? "split"); a.download = `${baseName}-${grid.columns}x${grid.rows}.zip`; a.click(); }, [zipBlobUrl, files, grid]); @@ -381,12 +399,20 @@ export function SplitSettings() { 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" > {processing && } - {processing ? "Splitting..." : `Split into ${tileCount} Tiles`} + {processing + ? "Splitting..." + : files.length > 1 + ? `Split ${files.length} Images (${tileCount} tiles each)` + : `Split into ${tileCount} Tiles`} {hasTiles && (
-

{tiles.length} Tiles Generated

+

+ {files.length > 1 + ? `${files.length} images split (${tiles.length} tiles each)` + : `${tiles.length} Tiles Generated`} +

>({ stripAll: true, @@ -267,7 +275,11 @@ export function StripMetadataSettings() { }, [currentFile, fileKey, metadataCache]); const handleProcess = () => { - processFiles(files, stripSettings); + if (files.length > 1) { + processAllFiles(files, stripSettings); + } else { + processFiles(files, stripSettings); + } }; const hasFile = files.length > 0; diff --git a/apps/web/src/components/tools/vectorize-settings.tsx b/apps/web/src/components/tools/vectorize-settings.tsx index cdac3b8..10f0fad 100644 --- a/apps/web/src/components/tools/vectorize-settings.tsx +++ b/apps/web/src/components/tools/vectorize-settings.tsx @@ -115,41 +115,92 @@ export function VectorizeSettings() { setError(null); setDownloadUrl(null); + const settingsJson = JSON.stringify({ + colorMode, + threshold, + colorPrecision, + layerDifference, + filterSpeckle, + pathMode, + cornerThreshold, + invert, + }); + try { - const formData = new FormData(); - formData.append("file", files[0]); - formData.append( - "settings", - JSON.stringify({ - colorMode, - threshold, - colorPrecision, - layerDifference, - filterSpeckle, - pathMode, - cornerThreshold, - invert, - }), - ); + if (files.length === 1) { + const formData = new FormData(); + formData.append("file", files[0]); + formData.append("settings", settingsJson); - const res = await fetch("/api/v1/tools/vectorize", { - method: "POST", - headers: formatHeaders(), - body: formData, - }); + const res = await fetch("/api/v1/tools/vectorize", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `Failed: ${res.status}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Failed: ${res.status}`); + } + + const result = await res.json(); + setJobId(result.jobId); + setProcessedUrl(result.downloadUrl); + setDownloadUrl(result.downloadUrl); + setOriginalSize(result.originalSize); + setProcessedSize(result.processedSize); + setSizes(result.originalSize, result.processedSize); + } else { + const { updateEntry, setBatchZip } = useFileStore.getState(); + const JSZip = (await import("jszip")).default; + const zip = new JSZip(); + let totalOriginal = 0; + let totalProcessed = 0; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const formData = new FormData(); + formData.append("file", file); + formData.append("settings", settingsJson); + + const res = await fetch("/api/v1/tools/vectorize", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + updateEntry(i, { + status: "failed", + error: body.error || `Failed: ${res.status}`, + }); + continue; + } + + const result = await res.json(); + totalOriginal += result.originalSize; + totalProcessed += result.processedSize; + + const svgRes = await fetch(result.downloadUrl, { headers: formatHeaders() }); + const svgBlob = await svgRes.blob(); + const svgName = file.name.replace(/\.[^.]+$/, ".svg"); + zip.file(svgName, svgBlob); + + updateEntry(i, { + processedUrl: result.downloadUrl, + processedSize: result.processedSize, + status: "completed", + error: null, + }); + } + + const zipBlob = await zip.generateAsync({ type: "blob" }); + setBatchZip(zipBlob, "vectorize-batch.zip"); + setOriginalSize(totalOriginal); + setProcessedSize(totalProcessed); + setSizes(totalOriginal, totalProcessed); } - - const result = await res.json(); - setJobId(result.jobId); - setProcessedUrl(result.downloadUrl); - setDownloadUrl(result.downloadUrl); - setOriginalSize(result.originalSize); - setProcessedSize(result.processedSize); - setSizes(result.originalSize, result.processedSize); } catch (err) { setError(err instanceof Error ? err.message : "Vectorization failed"); } finally { diff --git a/apps/web/src/hooks/use-pipeline-processor.ts b/apps/web/src/hooks/use-pipeline-processor.ts index 20c6b2f..de9c79d 100644 --- a/apps/web/src/hooks/use-pipeline-processor.ts +++ b/apps/web/src/hooks/use-pipeline-processor.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { formatHeaders } from "@/lib/api"; +import { formatHeaders, parseApiError } from "@/lib/api"; import { generateId } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; import type { PipelineStep } from "@/stores/pipeline-store"; @@ -145,10 +145,7 @@ export function usePipelineProcessor() { } else { try { const body = JSON.parse(xhr.responseText); - const msg = body.details - ? `${body.error}: ${body.details}` - : body.error || `Processing failed: ${xhr.status}`; - setError(msg); + setError(parseApiError(body, xhr.status)); } catch { setError(`Processing failed: ${xhr.status}`); } @@ -273,9 +270,7 @@ export function usePipelineProcessor() { errorMsg += ` (${body.errors.length} files failed)`; } } else { - errorMsg = body.details - ? `${body.error}: ${body.details}` - : body.error || `Batch processing failed: ${response.status}`; + errorMsg = parseApiError(body, response.status); } } catch { errorMsg = `Batch processing failed: ${response.status}`; diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index 5ac6731..7975fbe 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -1,6 +1,6 @@ import { PYTHON_SIDECAR_TOOLS } from "@ashim/shared"; import { useCallback, useEffect, useRef, useState } from "react"; -import { formatHeaders } from "@/lib/api"; +import { formatHeaders, parseApiError } from "@/lib/api"; import { generateId } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; @@ -233,10 +233,7 @@ export function useToolProcessor(toolId: string) { } else { try { const body = JSON.parse(xhr.responseText); - const msg = body.details - ? `${body.error}: ${body.details}` - : body.error || `Processing failed: ${xhr.status}`; - setError(msg); + setError(parseApiError(body, xhr.status)); } catch { setError(`Processing failed: ${xhr.status}`); } @@ -357,9 +354,7 @@ export function useToolProcessor(toolId: string) { let errorMsg: string; try { const body = JSON.parse(text); - errorMsg = body.details - ? `${body.error}: ${body.details}` - : body.error || `Batch processing failed: ${response.status}`; + errorMsg = parseApiError(body, response.status); } catch { errorMsg = `Batch processing failed: ${response.status}`; } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index e9fe704..f8b09f3 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,5 +1,26 @@ const API_BASE = "/api"; +export function parseApiError(body: Record, fallbackStatus: number): string { + const error = typeof body.error === "string" ? body.error : ""; + const details = body.details; + if (!details) { + return error || (body.message as string) || `Processing failed: ${fallbackStatus}`; + } + let detailsStr: string; + if (typeof details === "string") { + detailsStr = details; + } else if (Array.isArray(details)) { + detailsStr = details + .map((d) => + typeof d === "string" ? d : (d as Record)?.message || JSON.stringify(d), + ) + .join("; "); + } else { + detailsStr = JSON.stringify(details); + } + return error ? `${error}: ${detailsStr}` : detailsStr; +} + // ── Auth Headers ─────────────────────────────────────────────── function getToken(): string { diff --git a/docker/Dockerfile b/docker/Dockerfile index 4731cda..97a82f8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -291,14 +291,14 @@ ENV PORT=1349 \ CONCURRENT_JOBS=3 \ MAX_MEGAPIXELS=100 \ RATE_LIMIT_PER_MIN=100 \ - LOG_LEVEL=info + LOG_LEVEL=debug # NVIDIA Container Toolkit env vars (harmless on non-GPU systems) ENV NVIDIA_VISIBLE_DEVICES=all \ NVIDIA_DRIVER_CAPABILITIES=compute,utility # Suppress noisy ML library output in docker logs -ENV PYTHONWARNINGS=ignore \ +ENV PYTHONWARNINGS=default \ TF_CPP_MIN_LOG_LEVEL=3 \ PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True diff --git a/packages/ai/python/ocr.py b/packages/ai/python/ocr.py index 3e7e4df..a0f061d 100644 --- a/packages/ai/python/ocr.py +++ b/packages/ai/python/ocr.py @@ -229,10 +229,13 @@ def main(): emit_progress(10, "Detecting language") language = auto_detect_language(input_path) + engine_used = quality + # Route to engine based on quality tier if quality == "fast": try: text = run_tesseract(input_path, language, is_auto=was_auto) + engine_used = "tesseract" except FileNotFoundError: print(json.dumps({"success": False, "error": "Tesseract is not installed"})) sys.exit(1) @@ -240,39 +243,63 @@ def main(): elif quality == "balanced": try: text = run_paddleocr_v5(input_path, language) - except ImportError: - print(json.dumps({"success": False, "error": "PaddleOCR is not installed"})) + engine_used = "paddleocr-v5" + except ImportError as e: + print(json.dumps({"success": False, "error": f"PaddleOCR is not installed: {e}"})) sys.exit(1) - except Exception: - emit_progress(25, "Falling back") + except Exception as e: + print(json.dumps({ + "warning": f"PaddleOCR PP-OCRv5 failed ({type(e).__name__}: {e}), falling back to Tesseract" + }), file=sys.stderr, flush=True) + emit_progress(25, "PaddleOCR failed, falling back to Tesseract") try: text = run_tesseract(input_path, language, is_auto=was_auto) + engine_used = "tesseract (fallback from balanced)" except FileNotFoundError: - print(json.dumps({"success": False, "error": "OCR engines unavailable"})) + print(json.dumps({"success": False, "error": "OCR engines unavailable: PaddleOCR failed and Tesseract is not installed"})) sys.exit(1) elif quality == "best": try: text = run_paddleocr_vl(input_path) - except ImportError: - emit_progress(20, "Falling back") + engine_used = "paddleocr-vl" + except ImportError as e: + print(json.dumps({ + "warning": f"PaddleOCR-VL not available ({e}), trying PP-OCRv5" + }), file=sys.stderr, flush=True) + emit_progress(20, "VL model unavailable, trying PP-OCRv5") try: text = run_paddleocr_v5(input_path, language) - except Exception: + engine_used = "paddleocr-v5 (fallback from best)" + except Exception as e2: + print(json.dumps({ + "warning": f"PP-OCRv5 also failed ({type(e2).__name__}: {e2}), falling back to Tesseract" + }), file=sys.stderr, flush=True) + emit_progress(25, "PP-OCRv5 failed, falling back to Tesseract") text = run_tesseract(input_path, language, is_auto=was_auto) - except Exception: - emit_progress(20, "Falling back") + engine_used = "tesseract (fallback from best)" + except Exception as e: + print(json.dumps({ + "warning": f"PaddleOCR-VL failed ({type(e).__name__}: {e}), trying PP-OCRv5" + }), file=sys.stderr, flush=True) + emit_progress(20, "VL model failed, trying PP-OCRv5") try: text = run_paddleocr_v5(input_path, language) - except Exception: + engine_used = "paddleocr-v5 (fallback from best)" + except Exception as e2: + print(json.dumps({ + "warning": f"PP-OCRv5 also failed ({type(e2).__name__}: {e2}), falling back to Tesseract" + }), file=sys.stderr, flush=True) + emit_progress(25, "PP-OCRv5 failed, falling back to Tesseract") text = run_tesseract(input_path, language, is_auto=was_auto) + engine_used = "tesseract (fallback from best)" else: print(json.dumps({"success": False, "error": f"Unknown quality: {quality}"})) sys.exit(1) emit_progress(95, "Done") - print(json.dumps({"success": True, "text": text})) + print(json.dumps({"success": True, "text": text, "engine": engine_used})) except Exception as e: print(json.dumps({"success": False, "error": str(e)})) diff --git a/packages/ai/src/ocr.ts b/packages/ai/src/ocr.ts index faddf25..e3e067e 100644 --- a/packages/ai/src/ocr.ts +++ b/packages/ai/src/ocr.ts @@ -15,6 +15,7 @@ export interface OcrOptions { export interface OcrResult { text: string; + engine?: string; } export async function extractText( @@ -42,5 +43,6 @@ export async function extractText( return { text: result.text, + engine: result.engine, }; } diff --git a/playwright.docker.config.ts b/playwright.docker.config.ts new file mode 100644 index 0000000..eaa806d --- /dev/null +++ b/playwright.docker.config.ts @@ -0,0 +1,37 @@ +import path from "node:path"; +import { defineConfig, devices } from "@playwright/test"; + +const authFile = path.join(__dirname, "test-results", ".auth", "docker-user.json"); + +export default defineConfig({ + testDir: "./tests/e2e-docker", + timeout: 120_000, + expect: { + timeout: 30_000, + }, + fullyParallel: false, + retries: 0, + workers: 1, + reporter: [["html", { open: "never" }], ["list"]], + use: { + baseURL: "http://localhost:1349", + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + projects: [ + { + name: "setup", + testMatch: /auth\.setup\.ts/, + }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: authFile, + }, + dependencies: ["setup"], + }, + ], +}); + +export { authFile }; diff --git a/scripts/test-docker-fixes.sh b/scripts/test-docker-fixes.sh new file mode 100755 index 0000000..20a59c8 --- /dev/null +++ b/scripts/test-docker-fixes.sh @@ -0,0 +1,54 @@ +#!/bin/bash +set -e + +echo "=== Step 1: Stop existing container ===" +docker stop ashim 2>/dev/null || true +docker rm ashim 2>/dev/null || true + +echo "=== Step 2: Start test container ===" +docker run -d \ + --name ashim-test \ + -p 1349:1349 \ + -e AUTH_ENABLED=true \ + -e DEFAULT_USERNAME=admin \ + -e DEFAULT_PASSWORD=admin \ + -e LOG_LEVEL=debug \ + -e RATE_LIMIT_PER_MIN=50000 \ + -e SKIP_MUST_CHANGE_PASSWORD=true \ + ashim:test-fixes + +echo "=== Step 3: Wait for container to be healthy ===" +echo "Waiting for health check..." +for i in $(seq 1 60); do + STATUS=$(docker inspect --format='{{.State.Health.Status}}' ashim-test 2>/dev/null || echo "starting") + if [ "$STATUS" = "healthy" ]; then + echo "Container is healthy after ${i}0 seconds" + break + fi + if [ "$STATUS" = "unhealthy" ]; then + echo "Container is unhealthy!" + docker logs ashim-test --tail 50 + exit 1 + fi + echo " Status: $STATUS (${i}0s elapsed)" + sleep 10 +done + +echo "=== Step 4: Run Playwright tests ===" +npx playwright test --config playwright.docker.config.ts --reporter=list 2>&1 + +TEST_EXIT=$? + +echo "=== Step 5: Check Docker logs for errors ===" +echo "--- Container logs (last 50 lines) ---" +docker logs ashim-test --tail 50 + +echo "=== Step 6: Cleanup ===" +docker stop ashim-test +docker rm ashim-test + +# Restart original container +echo "=== Restarting original ashim container ===" +docker start ashim 2>/dev/null || true + +exit $TEST_EXIT diff --git a/tests/e2e-docker/auth.setup.ts b/tests/e2e-docker/auth.setup.ts new file mode 100644 index 0000000..23b3c27 --- /dev/null +++ b/tests/e2e-docker/auth.setup.ts @@ -0,0 +1,12 @@ +import { expect, test as setup } from "@playwright/test"; +import { authFile } from "../../playwright.docker.config"; + +setup("authenticate", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("Username").fill("admin"); + await page.getByLabel("Password").fill("admin"); + await page.getByRole("button", { name: /login/i }).click(); + await page.waitForURL("/", { timeout: 30_000 }); + await expect(page).toHaveURL("/"); + await page.context().storageState({ path: authFile }); +}); diff --git a/tests/e2e-docker/fixes-verification.spec.ts b/tests/e2e-docker/fixes-verification.spec.ts new file mode 100644 index 0000000..6d8fae1 --- /dev/null +++ b/tests/e2e-docker/fixes-verification.spec.ts @@ -0,0 +1,461 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expect, type Page, test } from "@playwright/test"; + +const SAMPLES_DIR = path.join(process.env.HOME ?? "/Users/sidd", "Downloads", "sample"); +const FIXTURES_DIR = path.join(process.cwd(), "tests", "fixtures"); + +function getSampleImage(name: string): string { + const p = path.join(SAMPLES_DIR, name); + if (fs.existsSync(p)) return p; + throw new Error(`Sample image not found: ${p}`); +} + +function getFixture(name: string): string { + return path.join(FIXTURES_DIR, name); +} + +async function uploadFiles(page: Page, filePaths: string[]): Promise { + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePaths); + await page.waitForTimeout(3000); +} + +async function waitForProcessingDone(page: Page, timeoutMs = 120_000): Promise { + try { + const spinner = page.locator("[class*='animate-spin']"); + if (await spinner.isVisible({ timeout: 3000 })) { + await spinner.waitFor({ state: "hidden", timeout: timeoutMs }); + } + } catch { + // No spinner — processing may have been instant + } + await page.waitForTimeout(500); +} + +// ─── 1. Error messages should never show [object Object] ───────────── + +test.describe("Error message formatting", () => { + test("tool validation error shows readable message, not [object Object]", async ({ request }) => { + // Send a malformed request directly to trigger a validation error + const res = await request.post("/api/v1/tools/resize", { + multipart: { + file: { + name: "test.jpg", + mimeType: "image/jpeg", + buffer: Buffer.from("not-an-image"), + }, + settings: JSON.stringify({ width: 0 }), + }, + }); + expect(res.ok()).toBe(false); + const body = await res.json(); + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + expect(body.error).not.toContain("[object Object]"); + if (body.details) { + expect(typeof body.details).toBe("string"); + expect(body.details).not.toContain("[object Object]"); + } + }); +}); + +// ─── 2. Image-to-PDF auth fix ──────────────────────────────────────── + +test.describe("Image-to-PDF", () => { + test("single image converts to PDF without auth error", async ({ page }) => { + await page.goto("/image-to-pdf"); + + // image-to-pdf has its own upload UI — use the Upload button + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByText("Upload from computer").click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(getFixture("test-100x100.jpg")); + await page.waitForTimeout(3000); + + const processBtn = page.getByRole("button", { name: /create pdf/i }); + await expect(processBtn).toBeEnabled({ timeout: 15_000 }); + await processBtn.click(); + await waitForProcessingDone(page); + + // Should NOT see "Authentication required" + const errorEl = page.locator(".text-red-500, [class*='text-red']"); + if (await errorEl.isVisible({ timeout: 3000 })) { + const text = await errorEl.textContent(); + expect(text).not.toContain("Authentication required"); + expect(text).not.toContain("[object Object]"); + } + + // Should see a download link + const downloadLink = page.locator("a[download], a[href*='download']"); + await expect(downloadLink).toBeVisible({ timeout: 15_000 }); + }); + + test("multiple images create multi-page PDF", async ({ page }) => { + await page.goto("/image-to-pdf"); + + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByText("Upload from computer").click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([getFixture("test-100x100.jpg"), getFixture("test-200x150.png")]); + await page.waitForTimeout(3000); + + const processBtn = page.getByRole("button", { name: /create pdf/i }); + await expect(processBtn).toBeEnabled({ timeout: 15_000 }); + await processBtn.click(); + await waitForProcessingDone(page); + + const errorEl = page.locator(".text-red-500, [class*='text-red']"); + if (await errorEl.isVisible({ timeout: 3000 })) { + const text = await errorEl.textContent(); + expect(text).not.toContain("Authentication required"); + } + + const downloadLink = page.locator("a[download], a[href*='download']"); + await expect(downloadLink).toBeVisible({ timeout: 15_000 }); + }); +}); + +// ─── 3. Split tool multi-file ──────────────────────────────────────── + +test.describe("Split tool", () => { + test("single image splits into tiles", async ({ page }) => { + await page.goto("/split"); + await uploadFiles(page, [getFixture("test-200x150.png")]); + + // Use 2x2 preset + await page.getByRole("button", { name: "2x2" }).click(); + + const processBtn = page.getByTestId("split-submit"); + await expect(processBtn).toBeEnabled({ timeout: 5000 }); + await processBtn.click(); + await waitForProcessingDone(page, 30_000); + + // Should see tile preview grid + const tiles = page.locator("button[title^='Download tile']"); + await expect(tiles.first()).toBeVisible({ timeout: 10_000 }); + const tileCount = await tiles.count(); + expect(tileCount).toBe(4); + + // ZIP download button should appear + await expect(page.getByRole("button", { name: /download all/i })).toBeVisible(); + + // No errors + const error = page.locator(".text-red-500"); + expect(await error.isVisible({ timeout: 1000 }).catch(() => false)).toBe(false); + }); + + test("multiple images all get split with subfolders in ZIP", async ({ page }) => { + await page.goto("/split"); + await uploadFiles(page, [getFixture("test-100x100.jpg"), getFixture("test-200x150.png")]); + + await page.getByRole("button", { name: "2x2" }).click(); + + const processBtn = page.getByTestId("split-submit"); + await expect(processBtn).toBeEnabled({ timeout: 5000 }); + + // Button should indicate multiple images + const btnText = await processBtn.textContent(); + expect(btnText).toContain("2 Images"); + + await processBtn.click(); + await waitForProcessingDone(page, 60_000); + + // ZIP download should appear + await expect(page.getByRole("button", { name: /download all/i })).toBeVisible({ + timeout: 15_000, + }); + + // Tile preview should show tiles from first image + const tiles = page.locator("button[title^='Download tile']"); + await expect(tiles.first()).toBeVisible({ timeout: 10_000 }); + + // Summary should mention both images + const summary = page.locator("text=images split"); + if (await summary.isVisible({ timeout: 2000 })) { + const text = await summary.textContent(); + expect(text).toContain("2"); + } + }); +}); + +// ─── 4. Batch processing (tools that had single-file bug) ──────────── + +test.describe("Batch processing fixes", () => { + test("strip-metadata processes multiple files", async ({ page }) => { + await page.goto("/strip-metadata"); + await uploadFiles(page, [getFixture("test-with-exif.jpg"), getFixture("test-100x100.jpg")]); + + // There should be a thumbnail strip with 2 entries + await page.waitForTimeout(500); + + const processBtn = page.getByRole("button", { name: /strip|remove|process/i }); + await expect(processBtn).toBeEnabled({ timeout: 5000 }); + await processBtn.click(); + await waitForProcessingDone(page, 30_000); + + // Should see ZIP download button for batch + const zipBtn = page.locator( + "button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')", + ); + await expect(zipBtn).toBeVisible({ timeout: 15_000 }); + }); + + test("blur-faces processes multiple files", async ({ page }) => { + await page.goto("/blur-faces"); + + // Use sample portraits + const portrait = path.join( + SAMPLES_DIR, + "free-photo-of-black-and-white-portrait-of-a-smiling-woman.jpeg", + ); + if (!fs.existsSync(portrait)) { + test.skip(); + return; + } + + await uploadFiles(page, [portrait, getFixture("test-portrait.jpg")]); + + const processBtn = page.getByRole("button", { name: /blur|process/i }); + await expect(processBtn).toBeEnabled({ timeout: 5000 }); + await processBtn.click(); + await waitForProcessingDone(page, 60_000); + + // Should see batch ZIP + const zipBtn = page.locator( + "button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')", + ); + await expect(zipBtn).toBeVisible({ timeout: 30_000 }); + }); + + test("vectorize processes multiple files", async ({ page }) => { + await page.goto("/vectorize"); + await uploadFiles(page, [getFixture("test-100x100.jpg"), getFixture("test-50x50.webp")]); + + const processBtn = page.getByTestId("vectorize-submit"); + await expect(processBtn).toBeEnabled({ timeout: 5000 }); + await processBtn.click(); + await waitForProcessingDone(page, 30_000); + + // Should see ZIP download for batch + const zipBtn = page.locator( + "button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')", + ); + await expect(zipBtn).toBeVisible({ timeout: 15_000 }); + }); +}); + +// ─── 5. Passport photo error handling ──────────────────────────────── + +test.describe("Passport photo", () => { + test("error message is readable, not [object Object]", async ({ page }) => { + await page.goto("/passport-photo"); + + // Upload a non-face image to trigger face detection failure + await uploadFiles(page, [getFixture("test-100x100.jpg")]); + + // Wait for auto-analyze to run and potentially fail + await page.waitForTimeout(5000); + + // Check for error message + const errorEl = page.locator(".text-red-500, [class*='text-red'], [class*='error']"); + if (await errorEl.isVisible({ timeout: 10_000 })) { + const text = await errorEl.textContent(); + expect(text).not.toContain("[object Object]"); + if (text && text.length > 0) { + expect(text.length).toBeGreaterThan(3); + } + } + }); + + test("passport photo works with real portrait", async ({ page }) => { + const portrait = path.join( + SAMPLES_DIR, + "free-photo-of-black-and-white-portrait-of-a-smiling-woman.jpeg", + ); + if (!fs.existsSync(portrait)) { + test.skip(); + return; + } + + await page.goto("/passport-photo"); + await uploadFiles(page, [portrait]); + + // Wait for face analysis (uses MediaPipe + rembg, can be slow on CPU) + await page.waitForTimeout(5000); + const analyzeSpinner = page.locator("[class*='animate-spin']").first(); + if (await analyzeSpinner.isVisible({ timeout: 3000 })) { + await analyzeSpinner.waitFor({ state: "hidden", timeout: 180_000 }); + } + + // Face detection should succeed — look for the Generate button + const generateBtn = page.getByRole("button", { name: /generate|create/i }); + const analyzeError = page.locator("p.text-red-500"); + const gotButton = await generateBtn.isVisible({ timeout: 10_000 }).catch(() => false); + const gotError = await analyzeError.isVisible({ timeout: 1000 }).catch(() => false); + if (gotError) { + const text = await analyzeError.textContent(); + expect(text).not.toContain("[object Object]"); + } + expect(gotButton || gotError).toBe(true); + }); +}); + +// ─── 6. OCR modes ──────────────────────────────────────────────────── + +test.describe("OCR", () => { + test("fast mode works and returns engine info", async ({ page }) => { + await page.goto("/ocr"); + await uploadFiles(page, [getFixture("test-100x100.jpg")]); + + // Select fast mode + const fastBtn = page.getByRole("button", { name: /fast/i }); + if (await fastBtn.isVisible({ timeout: 2000 })) { + await fastBtn.click(); + } + + const processBtn = page.getByRole("button", { name: /extract|scan|process/i }); + await expect(processBtn).toBeEnabled({ timeout: 5000 }); + await processBtn.click(); + await waitForProcessingDone(page, 60_000); + + // Should not show [object Object] + const errorEl = page.locator(".text-red-500, [class*='text-red']"); + if (await errorEl.isVisible({ timeout: 3000 })) { + const text = await errorEl.textContent(); + expect(text).not.toContain("[object Object]"); + } + }); + + test("balanced mode works", async ({ page }) => { + await page.goto("/ocr"); + await uploadFiles(page, [getFixture("test-100x100.jpg")]); + + const balancedBtn = page.getByRole("button", { name: /balanced/i }); + if (await balancedBtn.isVisible({ timeout: 2000 })) { + await balancedBtn.click(); + } + + const processBtn = page.getByRole("button", { name: /extract|scan|process/i }); + await expect(processBtn).toBeEnabled({ timeout: 5000 }); + await processBtn.click(); + await waitForProcessingDone(page, 120_000); + + const errorEl = page.locator(".text-red-500, [class*='text-red']"); + if (await errorEl.isVisible({ timeout: 5000 })) { + const text = await errorEl.textContent(); + expect(text).not.toContain("[object Object]"); + } + }); + + test("best mode works", async ({ page }) => { + await page.goto("/ocr"); + await uploadFiles(page, [getFixture("test-100x100.jpg")]); + + const bestBtn = page.getByRole("button", { name: /best/i }); + if (await bestBtn.isVisible({ timeout: 2000 })) { + await bestBtn.click(); + } + + const processBtn = page.getByRole("button", { name: /extract|scan|process/i }); + await expect(processBtn).toBeEnabled({ timeout: 5000 }); + await processBtn.click(); + await waitForProcessingDone(page, 120_000); + + const errorEl = page.locator(".text-red-500, [class*='text-red']"); + if (await errorEl.isVisible({ timeout: 5000 })) { + const text = await errorEl.textContent(); + expect(text).not.toContain("[object Object]"); + } + }); +}); + +// ─── 7. Common tools still work (regression check) ────────────────── + +test.describe("Regression checks", () => { + test("resize single image works", async ({ page }) => { + await page.goto("/resize"); + await uploadFiles(page, [getFixture("test-200x150.png")]); + + // Set explicit width so the button enables + const widthInput = page.getByLabel("Width (px)"); + await widthInput.fill("100"); + + const processBtn = page.getByTestId("resize-submit"); + await expect(processBtn).toBeEnabled({ timeout: 15_000 }); + await processBtn.click(); + await waitForProcessingDone(page); + + const error = page.locator(".text-red-500"); + expect(await error.isVisible({ timeout: 2000 }).catch(() => false)).toBe(false); + }); + + test("compress single image works", async ({ page }) => { + await page.goto("/compress"); + await uploadFiles(page, [getFixture("test-100x100.jpg")]); + + const processBtn = page.getByRole("button", { name: /compress/i }); + await expect(processBtn).toBeEnabled({ timeout: 5000 }); + await processBtn.click(); + await waitForProcessingDone(page); + + const error = page.locator(".text-red-500"); + expect(await error.isVisible({ timeout: 2000 }).catch(() => false)).toBe(false); + }); + + test("resize batch processes multiple files", async ({ page }) => { + await page.goto("/resize"); + await uploadFiles(page, [getFixture("test-100x100.jpg"), getFixture("test-200x150.png")]); + + // Set explicit width so the button enables + const widthInput = page.getByLabel("Width (px)"); + await widthInput.fill("50"); + + const processBtn = page.getByTestId("resize-submit"); + await expect(processBtn).toBeEnabled({ timeout: 15_000 }); + await processBtn.click(); + await waitForProcessingDone(page, 30_000); + + // Batch should produce ZIP + const zipBtn = page.locator( + "button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')", + ); + await expect(zipBtn).toBeVisible({ timeout: 15_000 }); + }); +}); + +// ─── 8. Docker container health and logging ────────────────────────── + +test.describe("Container health", () => { + test("health endpoint returns healthy", async ({ request }) => { + const res = await request.get("/api/v1/health"); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.status).toBe("healthy"); + }); + + test("API returns structured errors, not HTML", async ({ request }) => { + const res = await request.get("/api/v1/tools/nonexistent"); + const contentType = res.headers()["content-type"] ?? ""; + expect(contentType).toContain("application/json"); + }); + + test("error responses have string details, not objects", async ({ request }) => { + const formData = new URLSearchParams(); + const res = await request.post("/api/v1/tools/resize", { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + data: formData.toString(), + }); + expect(res.ok()).toBe(false); + const body = await res.json(); + if (body.details) { + expect(typeof body.details).toBe("string"); + } + if (body.error) { + expect(typeof body.error).toBe("string"); + } + }); +});