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