mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: verbose error handling, batch processing, and multi-file support
- Replace [object Object] errors with readable messages across all 20+ API routes by normalizing Zod validation errors to strings (formatZodErrors) - Add parseApiError() on frontend to defensively handle any details type - Add global Fastify error handler with full stack traces in logs - Fix image-to-pdf auth: Object.entries(headers) → headers.forEach() - Fix passport-photo: safeParse + formatZodErrors, safe error extraction - Fix OCR silent fallbacks: log exception type/message when falling back, include actual engine used in API response and Docker logs - Fix split tool: process all uploaded images, combine into ZIP with subfolders per image - Fix batch support for blur-faces, strip-metadata, edit-metadata, vectorize: add processAllFiles branch for multi-file uploads - Docker: LOG_LEVEL=debug, PYTHONWARNINGS=default for visibility - Add Playwright e2e tests verifying all fixes against Docker container
This commit is contained in:
@@ -38,6 +38,18 @@ const app = Fastify({
|
|||||||
bodyLimit: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024,
|
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
|
// Plugins
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: env.CORS_ORIGIN
|
origin: env.CORS_ORIGIN
|
||||||
|
|||||||
@@ -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("; ");
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import PQueue from "p-queue";
|
import PQueue from "p-queue";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { autoOrient } from "../lib/auto-orient.js";
|
import { autoOrient } from "../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
import { decodeHeic } from "../lib/heic-converter.js";
|
import { decodeHeic } from "../lib/heic-converter.js";
|
||||||
@@ -88,12 +89,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Invalid settings",
|
error: "Invalid settings",
|
||||||
details: result.error.issues.map(
|
details: formatZodErrors(result.error.issues),
|
||||||
(i: { path: (string | number)[]; message: string }) => ({
|
|
||||||
path: i.path.join("."),
|
|
||||||
message: i.message,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
settings = result.data;
|
settings = result.data;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { z } from "zod";
|
|||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
import { autoOrient } from "../lib/auto-orient.js";
|
import { autoOrient } from "../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
import { decodeHeic } from "../lib/heic-converter.js";
|
import { decodeHeic } from "../lib/heic-converter.js";
|
||||||
@@ -130,10 +131,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Invalid pipeline definition",
|
error: "Invalid pipeline definition",
|
||||||
details: result.error.issues.map((i) => ({
|
details: formatZodErrors(result.error.issues),
|
||||||
path: i.path.join("."),
|
|
||||||
message: i.message,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
pipeline = result.data;
|
pipeline = result.data;
|
||||||
@@ -431,10 +429,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Invalid pipeline definition",
|
error: "Invalid pipeline definition",
|
||||||
details: result.error.issues.map((i) => ({
|
details: formatZodErrors(result.error.issues),
|
||||||
path: i.path.join("."),
|
|
||||||
message: i.message,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
pipeline = result.data;
|
pipeline = result.data;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import sharp from "sharp";
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
import { autoOrient } from "../lib/auto-orient.js";
|
import { autoOrient } from "../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
import { decodeHeic } from "../lib/heic-converter.js";
|
import { decodeHeic } from "../lib/heic-converter.js";
|
||||||
@@ -185,10 +186,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Invalid settings",
|
error: "Invalid settings",
|
||||||
details: result.error.issues.map((i) => ({
|
details: formatZodErrors(result.error.issues),
|
||||||
path: i.path.join("."),
|
|
||||||
message: i.message,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
settings = result.data;
|
settings = result.data;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { basename, extname } from "node:path";
|
|||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
pattern: z.string().min(1).max(200).default("image-{{index}}"),
|
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 parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
const result = settingsSchema.safeParse(parsed);
|
const result = settingsSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
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;
|
settings = result.data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
@@ -467,7 +468,9 @@ export function registerCollage(app: FastifyInstance) {
|
|||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
const result = settingsSchema.safeParse(parsed);
|
const result = settingsSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
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;
|
settings = result.data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
@@ -74,7 +75,9 @@ export function registerCompose(app: FastifyInstance) {
|
|||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
const result = settingsSchema.safeParse(parsed);
|
const result = settingsSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
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;
|
settings = result.data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { seamCarve } from "@ashim/ai";
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
@@ -82,10 +83,7 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Invalid settings",
|
error: "Invalid settings",
|
||||||
details: result.error.issues.map((i) => ({
|
details: formatZodErrors(result.error.issues),
|
||||||
path: i.path.join("."),
|
|
||||||
message: i.message,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
settings = result.data;
|
settings = result.data;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import {
|
import {
|
||||||
buildTagArgs,
|
buildTagArgs,
|
||||||
type EditMetadataSettings,
|
type EditMetadataSettings,
|
||||||
@@ -150,10 +151,7 @@ export function registerEditMetadata(app: FastifyInstance) {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Invalid settings",
|
error: "Invalid settings",
|
||||||
details: result.error.issues.map((i) => ({
|
details: formatZodErrors(result.error.issues),
|
||||||
path: i.path.join("."),
|
|
||||||
message: i.message,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
settings = result.data;
|
settings = result.data;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import PDFDocument from "pdfkit";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
|
||||||
@@ -62,7 +63,9 @@ export function registerImageToPdf(app: FastifyInstance) {
|
|||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
const result = settingsSchema.safeParse(parsed);
|
const result = settingsSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
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;
|
settings = result.data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { basename } from "node:path";
|
|||||||
import { extractText } from "@ashim/ai";
|
import { extractText } from "@ashim/ai";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
import { updateSingleFileProgress } from "../progress.js";
|
import { updateSingleFileProgress } from "../progress.js";
|
||||||
@@ -66,7 +67,7 @@ export function registerOcr(app: FastifyInstance) {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return reply
|
return reply
|
||||||
.status(400)
|
.status(400)
|
||||||
.send({ error: "Invalid settings", details: result.error.issues });
|
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||||
}
|
}
|
||||||
settings = result.data;
|
settings = result.data;
|
||||||
} catch {
|
} 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({
|
return reply.send({
|
||||||
jobId,
|
jobId,
|
||||||
filename,
|
filename,
|
||||||
text: result.text,
|
text: result.text,
|
||||||
|
engine: result.engine,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err;
|
lastError = err;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
@@ -117,10 +118,7 @@ export function registerOptimizeForWeb(app: FastifyInstance) {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Invalid settings",
|
error: "Invalid settings",
|
||||||
details: result.error.issues.map((i) => ({
|
details: formatZodErrors(result.error.issues),
|
||||||
path: i.path.join("."),
|
|
||||||
message: i.message,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
settings = result.data;
|
settings = result.data;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
|
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
|
||||||
@@ -275,15 +276,14 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
|||||||
app.post(
|
app.post(
|
||||||
"/api/v1/tools/passport-photo/generate",
|
"/api/v1/tools/passport-photo/generate",
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
let parsed: z.infer<typeof generateSettingsSchema>;
|
const parseResult = generateSettingsSchema.safeParse(request.body);
|
||||||
try {
|
if (!parseResult.success) {
|
||||||
parsed = generateSettingsSchema.parse(request.body);
|
|
||||||
} catch (err) {
|
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Invalid settings",
|
error: "Invalid settings",
|
||||||
details: err instanceof Error ? err.message : String(err),
|
details: formatZodErrors(parseResult.error.issues),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const parsed = parseResult.data;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
jobId,
|
jobId,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import * as mupdf from "mupdf";
|
import * as mupdf from "mupdf";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { encodeHeic } from "../../lib/heic-converter.js";
|
import { encodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
|
||||||
@@ -290,7 +291,9 @@ export function registerPdfToImage(app: FastifyInstance) {
|
|||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
const result = settingsSchema.safeParse(parsed);
|
const result = settingsSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
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;
|
settings = result.data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -37,10 +38,7 @@ export function registerQrGenerate(app: FastifyInstance) {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Invalid settings",
|
error: "Invalid settings",
|
||||||
details: result.error.issues.map((i) => ({
|
details: formatZodErrors(result.error.issues),
|
||||||
path: i.path.join("."),
|
|
||||||
message: i.message,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -67,7 +68,9 @@ export function registerSplit(app: FastifyInstance) {
|
|||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
const result = settingsSchema.safeParse(parsed);
|
const result = settingsSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
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;
|
settings = result.data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
@@ -91,7 +92,9 @@ export function registerStitch(app: FastifyInstance) {
|
|||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
const result = settingsSchema.safeParse(parsed);
|
const result = settingsSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
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;
|
settings = result.data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import PQueue from "p-queue";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { env } from "../../config.js";
|
import { env } from "../../config.js";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
import { isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||||
@@ -147,7 +148,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Invalid settings",
|
error: "Invalid settings",
|
||||||
details: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
|
details: formatZodErrors(result.error.issues),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
settings = result.data;
|
settings = result.data;
|
||||||
@@ -365,7 +366,9 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
|||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
const result = settingsSchema.safeParse(parsed);
|
const result = settingsSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
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;
|
settings = result.data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import potrace from "potrace";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
|
||||||
@@ -82,7 +83,9 @@ export function registerVectorize(app: FastifyInstance) {
|
|||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
const result = settingsSchema.safeParse(parsed);
|
const result = settingsSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
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;
|
settings = result.data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -56,7 +57,9 @@ export function registerWatermarkImage(app: FastifyInstance) {
|
|||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
const result = settingsSchema.safeParse(parsed);
|
const result = settingsSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
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;
|
settings = result.data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -84,12 +84,24 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
|
|||||||
|
|
||||||
export function BlurFacesSettings() {
|
export function BlurFacesSettings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const {
|
||||||
useToolProcessor("blur-faces");
|
processFiles,
|
||||||
|
processAllFiles,
|
||||||
|
processing,
|
||||||
|
error,
|
||||||
|
downloadUrl,
|
||||||
|
originalSize,
|
||||||
|
processedSize,
|
||||||
|
progress,
|
||||||
|
} = useToolProcessor("blur-faces");
|
||||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
const handleProcess = () => {
|
const handleProcess = () => {
|
||||||
processFiles(files, settings);
|
if (files.length > 1) {
|
||||||
|
processAllFiles(files, settings);
|
||||||
|
} else {
|
||||||
|
processFiles(files, settings);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
|
|||||||
@@ -133,8 +133,16 @@ function LabeledInput({
|
|||||||
|
|
||||||
export function EditMetadataSettings() {
|
export function EditMetadataSettings() {
|
||||||
const { entries, selectedIndex, files } = useFileStore();
|
const { entries, selectedIndex, files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const {
|
||||||
useToolProcessor("edit-metadata");
|
processFiles,
|
||||||
|
processAllFiles,
|
||||||
|
processing,
|
||||||
|
error,
|
||||||
|
downloadUrl,
|
||||||
|
originalSize,
|
||||||
|
processedSize,
|
||||||
|
progress,
|
||||||
|
} = useToolProcessor("edit-metadata");
|
||||||
|
|
||||||
const [form, setForm] = useState<FormFields>(EMPTY_FORM);
|
const [form, setForm] = useState<FormFields>(EMPTY_FORM);
|
||||||
const [initialForm, setInitialForm] = useState<FormFields>(EMPTY_FORM);
|
const [initialForm, setInitialForm] = useState<FormFields>(EMPTY_FORM);
|
||||||
@@ -391,7 +399,11 @@ export function EditMetadataSettings() {
|
|||||||
settings.fieldsToRemove = Array.from(fieldsToRemove);
|
settings.fieldsToRemove = Array.from(fieldsToRemove);
|
||||||
}
|
}
|
||||||
|
|
||||||
processFiles(files, settings);
|
if (files.length > 1) {
|
||||||
|
processAllFiles(files, settings);
|
||||||
|
} else {
|
||||||
|
processFiles(files, settings);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -111,7 +111,13 @@ export function EraseObjectSettings({
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(xhr.responseText);
|
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 {
|
} catch {
|
||||||
setError(`Processing failed: ${xhr.status}`);
|
setError(`Processing failed: ${xhr.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,10 +220,9 @@ export function ImageToPdfSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
xhr.open("POST", "/api/v1/tools/image-to-pdf");
|
xhr.open("POST", "/api/v1/tools/image-to-pdf");
|
||||||
const headers = formatHeaders();
|
formatHeaders().forEach((value, key) => {
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
xhr.setRequestHeader(key, value);
|
||||||
xhr.setRequestHeader(key, value as string);
|
});
|
||||||
}
|
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
}, [files, pageSize, orientation, margin, setProcessing, setError]);
|
}, [files, pageSize, orientation, margin, setProcessing, setError]);
|
||||||
|
|
||||||
|
|||||||
@@ -442,7 +442,14 @@ export function PassportPhotoSettings() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.json().catch(() => null);
|
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();
|
const result = await response.json();
|
||||||
@@ -504,9 +511,14 @@ export function PassportPhotoSettings() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errBody = await response.json().catch(() => null);
|
const errBody = await response.json().catch(() => null);
|
||||||
throw new Error(
|
const msg = errBody
|
||||||
errBody?.details || errBody?.error || `Generation failed: ${response.status}`,
|
? 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();
|
const result: GenerateResult = await response.json();
|
||||||
|
|||||||
@@ -92,8 +92,6 @@ export function SplitSettings() {
|
|||||||
setZipBlobUrl(null);
|
setZipBlobUrl(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", files[0]);
|
|
||||||
const effectiveGrid = getEffectiveGrid();
|
const effectiveGrid = getEffectiveGrid();
|
||||||
const settings: Record<string, unknown> = {
|
const settings: Record<string, unknown> = {
|
||||||
columns: effectiveGrid.columns,
|
columns: effectiveGrid.columns,
|
||||||
@@ -107,41 +105,60 @@ export function SplitSettings() {
|
|||||||
if (LOSSY_FORMATS.has(outputFormat)) {
|
if (LOSSY_FORMATS.has(outputFormat)) {
|
||||||
settings.quality = quality;
|
settings.quality = quality;
|
||||||
}
|
}
|
||||||
formData.append("settings", JSON.stringify(settings));
|
const settingsJson = 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 JSZip = (await import("jszip")).default;
|
const JSZip = (await import("jszip")).default;
|
||||||
const zip = await JSZip.loadAsync(blob);
|
const combinedZip = new JSZip();
|
||||||
const tileEntries: Array<{ row: number; col: number; blobUrl: string | null }> = [];
|
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);
|
for (let fi = 0; fi < files.length; fi++) {
|
||||||
fileNames.sort();
|
const file = files[fi];
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("settings", settingsJson);
|
||||||
|
|
||||||
for (const name of fileNames) {
|
const res = await fetch("/api/v1/tools/split", {
|
||||||
const fileData = await zip.files[name].async("blob");
|
method: "POST",
|
||||||
const tileBlobUrl = URL.createObjectURL(fileData);
|
headers: formatHeaders(),
|
||||||
const match = name.match(/_r(\d+)_c(\d+)/);
|
body: formData,
|
||||||
const row = match ? Number.parseInt(match[1], 10) : 0;
|
});
|
||||||
const col = match ? Number.parseInt(match[2], 10) : 0;
|
if (!res.ok) {
|
||||||
tileEntries.push({ row, col, blobUrl: tileBlobUrl });
|
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(
|
setTiles(
|
||||||
tileEntries.map((t, i) => ({
|
previewTiles.map((t, i) => ({
|
||||||
row: t.row,
|
row: t.row,
|
||||||
col: t.col,
|
col: t.col,
|
||||||
label: `${i + 1}`,
|
label: `${i + 1}`,
|
||||||
@@ -173,7 +190,8 @@ export function SplitSettings() {
|
|||||||
if (!zipBlobUrl) return;
|
if (!zipBlobUrl) return;
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = zipBlobUrl;
|
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.download = `${baseName}-${grid.columns}x${grid.rows}.zip`;
|
||||||
a.click();
|
a.click();
|
||||||
}, [zipBlobUrl, files, grid]);
|
}, [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"
|
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 && <Loader2 className="h-4 w-4 animate-spin" />}
|
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{processing ? "Splitting..." : `Split into ${tileCount} Tiles`}
|
{processing
|
||||||
|
? "Splitting..."
|
||||||
|
: files.length > 1
|
||||||
|
? `Split ${files.length} Images (${tileCount} tiles each)`
|
||||||
|
: `Split into ${tileCount} Tiles`}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{hasTiles && (
|
{hasTiles && (
|
||||||
<div className="space-y-3 border-t border-border pt-3">
|
<div className="space-y-3 border-t border-border pt-3">
|
||||||
<p className="text-xs font-medium text-foreground">{tiles.length} Tiles Generated</p>
|
<p className="text-xs font-medium text-foreground">
|
||||||
|
{files.length > 1
|
||||||
|
? `${files.length} images split (${tiles.length} tiles each)`
|
||||||
|
: `${tiles.length} Tiles Generated`}
|
||||||
|
</p>
|
||||||
<div
|
<div
|
||||||
className="grid gap-1"
|
className="grid gap-1"
|
||||||
style={{ gridTemplateColumns: `repeat(${grid.columns}, 1fr)` }}
|
style={{ gridTemplateColumns: `repeat(${grid.columns}, 1fr)` }}
|
||||||
|
|||||||
@@ -197,8 +197,16 @@ export function StripMetadataControls({
|
|||||||
|
|
||||||
export function StripMetadataSettings() {
|
export function StripMetadataSettings() {
|
||||||
const { entries, selectedIndex, files } = useFileStore();
|
const { entries, selectedIndex, files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const {
|
||||||
useToolProcessor("strip-metadata");
|
processFiles,
|
||||||
|
processAllFiles,
|
||||||
|
processing,
|
||||||
|
error,
|
||||||
|
downloadUrl,
|
||||||
|
originalSize,
|
||||||
|
processedSize,
|
||||||
|
progress,
|
||||||
|
} = useToolProcessor("strip-metadata");
|
||||||
|
|
||||||
const [stripSettings, setStripSettings] = useState<Record<string, unknown>>({
|
const [stripSettings, setStripSettings] = useState<Record<string, unknown>>({
|
||||||
stripAll: true,
|
stripAll: true,
|
||||||
@@ -267,7 +275,11 @@ export function StripMetadataSettings() {
|
|||||||
}, [currentFile, fileKey, metadataCache]);
|
}, [currentFile, fileKey, metadataCache]);
|
||||||
|
|
||||||
const handleProcess = () => {
|
const handleProcess = () => {
|
||||||
processFiles(files, stripSettings);
|
if (files.length > 1) {
|
||||||
|
processAllFiles(files, stripSettings);
|
||||||
|
} else {
|
||||||
|
processFiles(files, stripSettings);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
|
|||||||
@@ -115,41 +115,92 @@ export function VectorizeSettings() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setDownloadUrl(null);
|
setDownloadUrl(null);
|
||||||
|
|
||||||
|
const settingsJson = JSON.stringify({
|
||||||
|
colorMode,
|
||||||
|
threshold,
|
||||||
|
colorPrecision,
|
||||||
|
layerDifference,
|
||||||
|
filterSpeckle,
|
||||||
|
pathMode,
|
||||||
|
cornerThreshold,
|
||||||
|
invert,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
if (files.length === 1) {
|
||||||
formData.append("file", files[0]);
|
const formData = new FormData();
|
||||||
formData.append(
|
formData.append("file", files[0]);
|
||||||
"settings",
|
formData.append("settings", settingsJson);
|
||||||
JSON.stringify({
|
|
||||||
colorMode,
|
|
||||||
threshold,
|
|
||||||
colorPrecision,
|
|
||||||
layerDifference,
|
|
||||||
filterSpeckle,
|
|
||||||
pathMode,
|
|
||||||
cornerThreshold,
|
|
||||||
invert,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await fetch("/api/v1/tools/vectorize", {
|
const res = await fetch("/api/v1/tools/vectorize", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: formatHeaders(),
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body.error || `Failed: ${res.status}`);
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Vectorization failed");
|
setError(err instanceof Error ? err.message : "Vectorization failed");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders, parseApiError } from "@/lib/api";
|
||||||
import { generateId } from "@/lib/utils";
|
import { generateId } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import type { PipelineStep } from "@/stores/pipeline-store";
|
import type { PipelineStep } from "@/stores/pipeline-store";
|
||||||
@@ -145,10 +145,7 @@ export function usePipelineProcessor() {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(xhr.responseText);
|
const body = JSON.parse(xhr.responseText);
|
||||||
const msg = body.details
|
setError(parseApiError(body, xhr.status));
|
||||||
? `${body.error}: ${body.details}`
|
|
||||||
: body.error || `Processing failed: ${xhr.status}`;
|
|
||||||
setError(msg);
|
|
||||||
} catch {
|
} catch {
|
||||||
setError(`Processing failed: ${xhr.status}`);
|
setError(`Processing failed: ${xhr.status}`);
|
||||||
}
|
}
|
||||||
@@ -273,9 +270,7 @@ export function usePipelineProcessor() {
|
|||||||
errorMsg += ` (${body.errors.length} files failed)`;
|
errorMsg += ` (${body.errors.length} files failed)`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
errorMsg = body.details
|
errorMsg = parseApiError(body, response.status);
|
||||||
? `${body.error}: ${body.details}`
|
|
||||||
: body.error || `Batch processing failed: ${response.status}`;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorMsg = `Batch processing failed: ${response.status}`;
|
errorMsg = `Batch processing failed: ${response.status}`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PYTHON_SIDECAR_TOOLS } from "@ashim/shared";
|
import { PYTHON_SIDECAR_TOOLS } from "@ashim/shared";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders, parseApiError } from "@/lib/api";
|
||||||
import { generateId } from "@/lib/utils";
|
import { generateId } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -233,10 +233,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(xhr.responseText);
|
const body = JSON.parse(xhr.responseText);
|
||||||
const msg = body.details
|
setError(parseApiError(body, xhr.status));
|
||||||
? `${body.error}: ${body.details}`
|
|
||||||
: body.error || `Processing failed: ${xhr.status}`;
|
|
||||||
setError(msg);
|
|
||||||
} catch {
|
} catch {
|
||||||
setError(`Processing failed: ${xhr.status}`);
|
setError(`Processing failed: ${xhr.status}`);
|
||||||
}
|
}
|
||||||
@@ -357,9 +354,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
let errorMsg: string;
|
let errorMsg: string;
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(text);
|
const body = JSON.parse(text);
|
||||||
errorMsg = body.details
|
errorMsg = parseApiError(body, response.status);
|
||||||
? `${body.error}: ${body.details}`
|
|
||||||
: body.error || `Batch processing failed: ${response.status}`;
|
|
||||||
} catch {
|
} catch {
|
||||||
errorMsg = `Batch processing failed: ${response.status}`;
|
errorMsg = `Batch processing failed: ${response.status}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
const API_BASE = "/api";
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
export function parseApiError(body: Record<string, unknown>, 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<string, unknown>)?.message || JSON.stringify(d),
|
||||||
|
)
|
||||||
|
.join("; ");
|
||||||
|
} else {
|
||||||
|
detailsStr = JSON.stringify(details);
|
||||||
|
}
|
||||||
|
return error ? `${error}: ${detailsStr}` : detailsStr;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Auth Headers ───────────────────────────────────────────────
|
// ── Auth Headers ───────────────────────────────────────────────
|
||||||
|
|
||||||
function getToken(): string {
|
function getToken(): string {
|
||||||
|
|||||||
+2
-2
@@ -291,14 +291,14 @@ ENV PORT=1349 \
|
|||||||
CONCURRENT_JOBS=3 \
|
CONCURRENT_JOBS=3 \
|
||||||
MAX_MEGAPIXELS=100 \
|
MAX_MEGAPIXELS=100 \
|
||||||
RATE_LIMIT_PER_MIN=100 \
|
RATE_LIMIT_PER_MIN=100 \
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
# NVIDIA Container Toolkit env vars (harmless on non-GPU systems)
|
# NVIDIA Container Toolkit env vars (harmless on non-GPU systems)
|
||||||
ENV NVIDIA_VISIBLE_DEVICES=all \
|
ENV NVIDIA_VISIBLE_DEVICES=all \
|
||||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
|
||||||
# Suppress noisy ML library output in docker logs
|
# Suppress noisy ML library output in docker logs
|
||||||
ENV PYTHONWARNINGS=ignore \
|
ENV PYTHONWARNINGS=default \
|
||||||
TF_CPP_MIN_LOG_LEVEL=3 \
|
TF_CPP_MIN_LOG_LEVEL=3 \
|
||||||
PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
|
PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
|
||||||
|
|
||||||
|
|||||||
+39
-12
@@ -229,10 +229,13 @@ def main():
|
|||||||
emit_progress(10, "Detecting language")
|
emit_progress(10, "Detecting language")
|
||||||
language = auto_detect_language(input_path)
|
language = auto_detect_language(input_path)
|
||||||
|
|
||||||
|
engine_used = quality
|
||||||
|
|
||||||
# Route to engine based on quality tier
|
# Route to engine based on quality tier
|
||||||
if quality == "fast":
|
if quality == "fast":
|
||||||
try:
|
try:
|
||||||
text = run_tesseract(input_path, language, is_auto=was_auto)
|
text = run_tesseract(input_path, language, is_auto=was_auto)
|
||||||
|
engine_used = "tesseract"
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print(json.dumps({"success": False, "error": "Tesseract is not installed"}))
|
print(json.dumps({"success": False, "error": "Tesseract is not installed"}))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -240,39 +243,63 @@ def main():
|
|||||||
elif quality == "balanced":
|
elif quality == "balanced":
|
||||||
try:
|
try:
|
||||||
text = run_paddleocr_v5(input_path, language)
|
text = run_paddleocr_v5(input_path, language)
|
||||||
except ImportError:
|
engine_used = "paddleocr-v5"
|
||||||
print(json.dumps({"success": False, "error": "PaddleOCR is not installed"}))
|
except ImportError as e:
|
||||||
|
print(json.dumps({"success": False, "error": f"PaddleOCR is not installed: {e}"}))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
emit_progress(25, "Falling back")
|
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:
|
try:
|
||||||
text = run_tesseract(input_path, language, is_auto=was_auto)
|
text = run_tesseract(input_path, language, is_auto=was_auto)
|
||||||
|
engine_used = "tesseract (fallback from balanced)"
|
||||||
except FileNotFoundError:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
elif quality == "best":
|
elif quality == "best":
|
||||||
try:
|
try:
|
||||||
text = run_paddleocr_vl(input_path)
|
text = run_paddleocr_vl(input_path)
|
||||||
except ImportError:
|
engine_used = "paddleocr-vl"
|
||||||
emit_progress(20, "Falling back")
|
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:
|
try:
|
||||||
text = run_paddleocr_v5(input_path, language)
|
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)
|
text = run_tesseract(input_path, language, is_auto=was_auto)
|
||||||
except Exception:
|
engine_used = "tesseract (fallback from best)"
|
||||||
emit_progress(20, "Falling back")
|
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:
|
try:
|
||||||
text = run_paddleocr_v5(input_path, language)
|
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)
|
text = run_tesseract(input_path, language, is_auto=was_auto)
|
||||||
|
engine_used = "tesseract (fallback from best)"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(json.dumps({"success": False, "error": f"Unknown quality: {quality}"}))
|
print(json.dumps({"success": False, "error": f"Unknown quality: {quality}"}))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
emit_progress(95, "Done")
|
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:
|
except Exception as e:
|
||||||
print(json.dumps({"success": False, "error": str(e)}))
|
print(json.dumps({"success": False, "error": str(e)}))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface OcrOptions {
|
|||||||
|
|
||||||
export interface OcrResult {
|
export interface OcrResult {
|
||||||
text: string;
|
text: string;
|
||||||
|
engine?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractText(
|
export async function extractText(
|
||||||
@@ -42,5 +43,6 @@ export async function extractText(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
text: result.text,
|
text: result.text,
|
||||||
|
engine: result.engine,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
Executable
+54
@@ -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
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user