diff --git a/tests/e2e-docker/batch-workflows.spec.ts b/tests/e2e-docker/batch-workflows.spec.ts new file mode 100644 index 00000000..a861cd1a --- /dev/null +++ b/tests/e2e-docker/batch-workflows.spec.ts @@ -0,0 +1,741 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +// ─── Batch Workflows ────────────────────────────────────────────── +// Comprehensive batch processing tests for every tool category. +// Each batch uploads 3-5 files, processes them, and verifies all +// outputs are valid (ZIP download or JSON with downloadUrl). + +const FIXTURES = join(process.cwd(), "tests", "fixtures"); +const FORMATS = join(FIXTURES, "formats"); +const CONTENT = join(FIXTURES, "content"); + +let token: string; + +test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "admin" }, + }); + const body = await res.json(); + token = body.token; +}); + +function fixture(name: string): Buffer { + return readFileSync(join(FIXTURES, name)); +} + +function formatFixture(name: string): Buffer { + return readFileSync(join(FORMATS, name)); +} + +function contentFixture(name: string): Buffer { + return readFileSync(join(CONTENT, name)); +} + +/** + * Build a raw multipart/form-data body. Playwright's `multipart` option + * does not support arrays for the same field name, so multi-file uploads + * must be assembled manually. + */ +function buildMultipart( + files: Array<{ name: string; filename: string; contentType: string; buffer: Buffer }>, + fields: Array<{ name: string; value: string }>, +): { body: Buffer; contentType: string } { + const boundary = `----PlaywrightBoundary${Date.now()}`; + const parts: Buffer[] = []; + for (const file of files) { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, + ), + ); + parts.push(file.buffer); + parts.push(Buffer.from("\r\n")); + } + for (const field of fields) { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${field.name}"\r\n\r\n${field.value}\r\n`, + ), + ); + } + parts.push(Buffer.from(`--${boundary}--\r\n`)); + return { + body: Buffer.concat(parts), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +/** + * Assert a batch response is valid: either a JSON with downloadUrl + * or a raw ZIP binary (PK magic bytes). + */ +async function expectBatchSuccess(res: import("@playwright/test").APIResponse): Promise { + expect(res.ok()).toBe(true); + const resContentType = res.headers()["content-type"] ?? ""; + if (resContentType.includes("application/json")) { + const json = await res.json(); + expect(json.downloadUrl).toBeTruthy(); + } else { + const buffer = Buffer.from(await res.body()); + expect(buffer.length).toBeGreaterThan(0); + // ZIP magic bytes: PK\x03\x04 + expect(buffer[0]).toBe(0x50); + expect(buffer[1]).toBe(0x4b); + } +} + +const PNG_200x150 = fixture("test-200x150.png"); +const JPG_100x100 = fixture("test-100x100.jpg"); +const WEBP_50x50 = fixture("test-50x50.webp"); +const HEIC_200x150 = fixture("test-200x150.heic"); +const JPG_SAMPLE = formatFixture("sample.jpg"); + +// ─── Essential: Batch Resize 5 Images ───────────────────────────── + +test.describe("Essential: batch resize 5 images", () => { + test("resize 5 mixed-format images to 120px wide", async ({ request }) => { + const avifSample = formatFixture("sample.avif"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.heic", contentType: "image/heic", buffer: HEIC_200x150 }, + { name: "file", filename: "e.avif", contentType: "image/avif", buffer: avifSample }, + ], + [{ name: "settings", value: JSON.stringify({ width: 120, fit: "contain" }) }], + ); + const res = await request.post("/api/v1/tools/resize/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); + + test("resize 5 images with explicit width and height", async ({ request }) => { + const tiffSample = formatFixture("sample.tiff"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + { name: "file", filename: "e.tiff", contentType: "image/tiff", buffer: tiffSample }, + ], + [{ name: "settings", value: JSON.stringify({ width: 64, height: 64, fit: "cover" }) }], + ); + const res = await request.post("/api/v1/tools/resize/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Essential: Batch Compress ──────────────────────────────────── + +test.describe("Essential: batch compress", () => { + test("compress 5 images with aggressive quality", async ({ request }) => { + const pngSample = formatFixture("sample.png"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + { name: "file", filename: "e.png", contentType: "image/png", buffer: pngSample }, + ], + [{ name: "settings", value: JSON.stringify({ quality: 20 }) }], + ); + const res = await request.post("/api/v1/tools/compress/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); + + test("compress 3 images with moderate quality", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + { name: "file", filename: "c.heic", contentType: "image/heic", buffer: HEIC_200x150 }, + ], + [{ name: "settings", value: JSON.stringify({ quality: 60 }) }], + ); + const res = await request.post("/api/v1/tools/compress/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Essential: Batch Rotate ────────────────────────────────────── + +test.describe("Essential: batch rotate", () => { + test("rotate 4 images by 90 degrees", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.heic", contentType: "image/heic", buffer: HEIC_200x150 }, + ], + [{ name: "settings", value: JSON.stringify({ angle: 90 }) }], + ); + const res = await request.post("/api/v1/tools/rotate/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); + + test("rotate 3 images by 45 degrees with white background", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + ], + [{ name: "settings", value: JSON.stringify({ angle: 45, background: "#ffffff" }) }], + ); + const res = await request.post("/api/v1/tools/rotate/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Adjustment: Batch Color Adjustments ────────────────────────── + +test.describe("Adjustment: batch color adjustments", () => { + test("adjust brightness and contrast on 4 images", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + ], + [ + { + name: "settings", + value: JSON.stringify({ brightness: 25, contrast: 15, saturation: -10 }), + }, + ], + ); + const res = await request.post("/api/v1/tools/adjust-colors/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); + + test("batch grayscale conversion on 5 images", async ({ request }) => { + const avifSample = formatFixture("sample.avif"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.heic", contentType: "image/heic", buffer: HEIC_200x150 }, + { name: "file", filename: "e.avif", contentType: "image/avif", buffer: avifSample }, + ], + [{ name: "settings", value: JSON.stringify({ grayscale: true }) }], + ); + const res = await request.post("/api/v1/tools/adjust-colors/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); + + test("batch negative brightness on 3 images", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + ], + [{ name: "settings", value: JSON.stringify({ brightness: -30, contrast: 20 }) }], + ); + const res = await request.post("/api/v1/tools/adjust-colors/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Adjustment: Batch Sharpening ───────────────────────────────── + +test.describe("Adjustment: batch sharpening", () => { + test("sharpen 4 images with sigma 2.0", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + ], + [{ name: "settings", value: JSON.stringify({ sigma: 2.0 }) }], + ); + const res = await request.post("/api/v1/tools/sharpening/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); + + test("sharpen 3 images with low sigma for subtle effect", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.heic", contentType: "image/heic", buffer: HEIC_200x150 }, + { name: "file", filename: "b.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "c.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + ], + [{ name: "settings", value: JSON.stringify({ sigma: 0.5 }) }], + ); + const res = await request.post("/api/v1/tools/sharpening/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Overlay: Batch Watermark Text ──────────────────────────────── + +test.describe("Overlay: batch watermark text", () => { + test("watermark 5 images with tiled text", async ({ request }) => { + const webpSample = formatFixture("sample.webp"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + { name: "file", filename: "e.webp", contentType: "image/webp", buffer: webpSample }, + ], + [ + { + name: "settings", + value: JSON.stringify({ + text: "CONFIDENTIAL", + fontSize: 18, + color: "#ff0000", + opacity: 30, + position: "tiled", + rotation: -45, + }), + }, + ], + ); + const res = await request.post("/api/v1/tools/watermark-text/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); + + test("watermark 3 images at bottom-right", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + { name: "file", filename: "c.heic", contentType: "image/heic", buffer: HEIC_200x150 }, + ], + [ + { + name: "settings", + value: JSON.stringify({ + text: "@snapotter", + fontSize: 14, + color: "#ffffff", + opacity: 50, + position: "bottom-right", + }), + }, + ], + ); + const res = await request.post("/api/v1/tools/watermark-text/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Overlay: Batch Watermark Image ─────────────────────────────── + +test.describe("Overlay: batch watermark image", () => { + test("watermark 3 images with a logo overlay", async ({ request }) => { + const watermarkImg = contentFixture("watermark.jpg"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { + name: "watermark", + filename: "logo.jpg", + contentType: "image/jpeg", + buffer: watermarkImg, + }, + ], + [ + { + name: "settings", + value: JSON.stringify({ position: "bottom-right", opacity: 50, scale: 20 }), + }, + ], + ); + const res = await request.post("/api/v1/tools/watermark-image/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + // watermark-image batch may not be registered + if (res.status() === 404) { + const json = await res.json(); + expect(json.error).toBeDefined(); + return; + } + await expectBatchSuccess(res); + }); +}); + +// ─── Format: Batch Convert JPEG to PNG ──────────────────────────── + +test.describe("Format: batch convert JPEG to PNG", () => { + test("convert 5 JPEG files to PNG", async ({ request }) => { + const portrait = contentFixture("portrait-color.jpg"); + const portraitBw = contentFixture("portrait-bw.jpeg"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + { + name: "file", + filename: "c.jpg", + contentType: "image/jpeg", + buffer: fixture("test-with-exif.jpg"), + }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: portrait }, + { name: "file", filename: "e.jpeg", contentType: "image/jpeg", buffer: portraitBw }, + ], + [{ name: "settings", value: JSON.stringify({ format: "png" }) }], + ); + const res = await request.post("/api/v1/tools/convert/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); + + test("convert 4 mixed formats to WebP", async ({ request }) => { + const tiffSample = formatFixture("sample.tiff"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + { name: "file", filename: "c.heic", contentType: "image/heic", buffer: HEIC_200x150 }, + { name: "file", filename: "d.tiff", contentType: "image/tiff", buffer: tiffSample }, + ], + [{ name: "settings", value: JSON.stringify({ format: "webp", quality: 80 }) }], + ); + const res = await request.post("/api/v1/tools/convert/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); + + test("convert 3 formats to AVIF", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + ], + [{ name: "settings", value: JSON.stringify({ format: "avif" }) }], + ); + const res = await request.post("/api/v1/tools/convert/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Utility: Batch Info ────────────────────────────────────────── + +test.describe("Utility: batch info", () => { + test("get info for 5 images of different formats", async ({ request }) => { + const tiffSample = formatFixture("sample.tiff"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.heic", contentType: "image/heic", buffer: HEIC_200x150 }, + { name: "file", filename: "e.tiff", contentType: "image/tiff", buffer: tiffSample }, + ], + [], + ); + const res = await request.post("/api/v1/tools/info/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + // info/batch may not exist as a batch endpoint + if (res.status() === 404) { + const json = await res.json(); + expect(json.error).toBeDefined(); + return; + } + expect(res.ok()).toBe(true); + const json = await res.json(); + // Batch info should return metadata for each file + if (json.results) { + expect(json.results).toBeInstanceOf(Array); + expect(json.results.length).toBe(5); + } else if (json.downloadUrl) { + expect(json.downloadUrl).toBeTruthy(); + } + }); +}); + +// ─── Utility: Batch Strip Metadata ──────────────────────────────── + +test.describe("Utility: batch strip metadata", () => { + test("strip metadata from 4 images", async ({ request }) => { + const jpgExif = fixture("test-with-exif.jpg"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.jpg", contentType: "image/jpeg", buffer: jpgExif }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + ], + [{ name: "settings", value: JSON.stringify({}) }], + ); + const res = await request.post("/api/v1/tools/strip-metadata/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); + + test("strip metadata from 3 images including HEIC", async ({ request }) => { + const jpgExif = fixture("test-with-exif.jpg"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.jpg", contentType: "image/jpeg", buffer: jpgExif }, + { name: "file", filename: "b.heic", contentType: "image/heic", buffer: HEIC_200x150 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + ], + [{ name: "settings", value: JSON.stringify({}) }], + ); + const res = await request.post("/api/v1/tools/strip-metadata/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Essential: Batch Crop ──────────────────────────────────────── + +test.describe("Essential: batch crop", () => { + test("crop 4 images to a small center region", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + ], + [ + { + name: "settings", + value: JSON.stringify({ left: 5, top: 5, width: 40, height: 40 }), + }, + ], + ); + const res = await request.post("/api/v1/tools/crop/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Overlay: Batch Text Overlay ────────────────────────────────── + +test.describe("Overlay: batch text overlay", () => { + test("text overlay on 4 images with background box", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + ], + [ + { + name: "settings", + value: JSON.stringify({ + text: "BATCH CAPTION", + fontSize: 16, + color: "#ffffff", + position: "bottom", + backgroundBox: true, + backgroundColor: "#000000", + }), + }, + ], + ); + const res = await request.post("/api/v1/tools/text-overlay/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Format: Batch Convert to TIFF ──────────────────────────────── + +test.describe("Format: batch convert to TIFF", () => { + test("convert 3 images to TIFF", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + ], + [{ name: "settings", value: JSON.stringify({ format: "tiff" }) }], + ); + const res = await request.post("/api/v1/tools/convert/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Adjustment: Batch Enhance ──────────────────────────────────── + +test.describe("Adjustment: batch enhance", () => { + test("enhance 4 images with vivid preset", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + ], + [{ name: "settings", value: JSON.stringify({ preset: "vivid" }) }], + ); + const res = await request.post("/api/v1/tools/image-enhancement/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Utility: Batch Optimize for Web ────────────────────────────── + +test.describe("Utility: batch optimize for web", () => { + test("optimize 5 images for web delivery", async ({ request }) => { + const webpSample = formatFixture("sample.webp"); + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE }, + { name: "file", filename: "e.webp", contentType: "image/webp", buffer: webpSample }, + ], + [{ name: "settings", value: JSON.stringify({ maxWidth: 600, quality: 65 }) }], + ); + const res = await request.post("/api/v1/tools/optimize-for-web/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Overlay: Batch Border ──────────────────────────────────────── + +test.describe("Overlay: batch border", () => { + test("add 20px red border to 4 images", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + { name: "file", filename: "d.heic", contentType: "image/heic", buffer: HEIC_200x150 }, + ], + [{ name: "settings", value: JSON.stringify({ size: 20, color: "#ff0000" }) }], + ); + const res = await request.post("/api/v1/tools/border/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Format: Batch Convert to GIF ───────────────────────────────── + +test.describe("Format: batch convert to GIF", () => { + test("convert 3 images to GIF", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + ], + [{ name: "settings", value: JSON.stringify({ format: "gif" }) }], + ); + const res = await request.post("/api/v1/tools/convert/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); + +// ─── Adjustment: Batch Replace Color ────────────────────────────── + +test.describe("Adjustment: batch replace color", () => { + test("replace white with light gray in 3 images", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + ], + [ + { + name: "settings", + value: JSON.stringify({ + targetColor: "#ffffff", + replacementColor: "#e0e0e0", + tolerance: 30, + }), + }, + ], + ); + const res = await request.post("/api/v1/tools/replace-color/batch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + await expectBatchSuccess(res); + }); +}); diff --git a/tests/e2e-docker/cross-format-docker.spec.ts b/tests/e2e-docker/cross-format-docker.spec.ts new file mode 100644 index 00000000..8a4cd9ae --- /dev/null +++ b/tests/e2e-docker/cross-format-docker.spec.ts @@ -0,0 +1,915 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +// ─── Cross-Format Docker Tests ──────────────────────────────────── +// Tests key tools against multiple input formats through the real +// Docker container with real Sharp processing. Verifies actual +// output dimensions, format, and file size. + +const FIXTURES = join(process.cwd(), "tests", "fixtures"); +const FORMATS = join(FIXTURES, "formats"); + +let token: string; + +test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "admin" }, + }); + const body = await res.json(); + token = body.token; +}); + +function fixture(name: string): Buffer { + return readFileSync(join(FIXTURES, name)); +} + +function formatFixture(name: string): Buffer { + return readFileSync(join(FORMATS, name)); +} + +const PNG_200x150 = fixture("test-200x150.png"); +const JPG_100x100 = fixture("test-100x100.jpg"); +const WEBP_50x50 = fixture("test-50x50.webp"); +const HEIC_200x150 = fixture("test-200x150.heic"); + +// ─── Resize: Format Matrix ─────────────────────────────────────── + +test.describe("Resize: JPEG", () => { + test("resize JPEG to 50px wide", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ width: 50, fit: "contain" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Resize: PNG", () => { + test("resize PNG to 80px wide", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ width: 80, fit: "contain" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Resize: WebP", () => { + test("resize WebP to 30px wide", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.webp", mimeType: "image/webp", buffer: WEBP_50x50 }, + settings: JSON.stringify({ width: 30, fit: "contain" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Resize: AVIF", () => { + test("resize AVIF to 60px wide", async ({ request }) => { + const avif = formatFixture("sample.avif"); + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.avif", mimeType: "image/avif", buffer: avif }, + settings: JSON.stringify({ width: 60, fit: "contain" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Resize: HEIC", () => { + test("resize HEIC to 80px wide", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + settings: JSON.stringify({ width: 80, fit: "contain" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Resize: TIFF", () => { + test("resize TIFF to 60px wide", async ({ request }) => { + const tiff = formatFixture("sample.tiff"); + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.tiff", mimeType: "image/tiff", buffer: tiff }, + settings: JSON.stringify({ width: 60, fit: "contain" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Resize: BMP", () => { + test("resize BMP to 40px wide", async ({ request }) => { + const bmp = formatFixture("sample.bmp"); + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.bmp", mimeType: "image/bmp", buffer: bmp }, + settings: JSON.stringify({ width: 40, fit: "contain" }), + }, + }); + // BMP may not be supported by Sharp natively + if (res.ok()) { + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + } else { + expect([400, 422]).toContain(res.status()); + } + }); +}); + +test.describe("Resize: GIF", () => { + test("resize GIF to 40px wide", async ({ request }) => { + const gif = formatFixture("sample.gif"); + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.gif", mimeType: "image/gif", buffer: gif }, + settings: JSON.stringify({ width: 40, fit: "contain" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +// ─── Convert: From Each Format to PNG ───────────────────────────── + +test.describe("Convert to PNG: from JPEG", () => { + test("JPEG to PNG", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ format: "png" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Convert to PNG: from WebP", () => { + test("WebP to PNG", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.webp", mimeType: "image/webp", buffer: WEBP_50x50 }, + settings: JSON.stringify({ format: "png" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Convert to PNG: from AVIF", () => { + test("AVIF to PNG", async ({ request }) => { + const avif = formatFixture("sample.avif"); + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.avif", mimeType: "image/avif", buffer: avif }, + settings: JSON.stringify({ format: "png" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Convert to PNG: from HEIC", () => { + test("HEIC to PNG", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + settings: JSON.stringify({ format: "png" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Convert to PNG: from TIFF", () => { + test("TIFF to PNG", async ({ request }) => { + const tiff = formatFixture("sample.tiff"); + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.tiff", mimeType: "image/tiff", buffer: tiff }, + settings: JSON.stringify({ format: "png" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Convert to PNG: from GIF", () => { + test("GIF to PNG", async ({ request }) => { + const gif = formatFixture("sample.gif"); + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.gif", mimeType: "image/gif", buffer: gif }, + settings: JSON.stringify({ format: "png" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Convert to PNG: from HEIF", () => { + test("HEIF to PNG", async ({ request }) => { + const heif = formatFixture("sample.heif"); + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.heif", mimeType: "image/heif", buffer: heif }, + settings: JSON.stringify({ format: "png" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Convert to PNG: from BMP", () => { + test("BMP to PNG", async ({ request }) => { + const bmp = formatFixture("sample.bmp"); + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.bmp", mimeType: "image/bmp", buffer: bmp }, + settings: JSON.stringify({ format: "png" }), + }, + }); + // BMP decode may not be supported + if (res.ok()) { + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + expect(body.processedSize).toBeGreaterThan(0); + } else { + expect([400, 422]).toContain(res.status()); + } + }); +}); + +// ─── Compress: Format Matrix ────────────────────────────────────── + +test.describe("Compress: JPEG", () => { + test("compress JPEG with quality 30", async ({ request }) => { + const res = await request.post("/api/v1/tools/compress", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: formatFixture("sample.jpg") }, + settings: JSON.stringify({ quality: 30 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + expect(body.processedSize).toBeLessThan(body.originalSize); + }); +}); + +test.describe("Compress: PNG", () => { + test("compress PNG with quality 40", async ({ request }) => { + const res = await request.post("/api/v1/tools/compress", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ quality: 40 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Compress: WebP", () => { + test("compress WebP with quality 25", async ({ request }) => { + const webp = formatFixture("sample.webp"); + const res = await request.post("/api/v1/tools/compress", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.webp", mimeType: "image/webp", buffer: webp }, + settings: JSON.stringify({ quality: 25 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Compress: AVIF", () => { + test("compress AVIF with quality 40", async ({ request }) => { + const avif = formatFixture("sample.avif"); + const res = await request.post("/api/v1/tools/compress", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.avif", mimeType: "image/avif", buffer: avif }, + settings: JSON.stringify({ quality: 40 }), + }, + }); + // AVIF compression may not be supported + if (res.ok()) { + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + } else { + expect([400, 422]).toContain(res.status()); + } + }); +}); + +test.describe("Compress: HEIC", () => { + test("compress HEIC with quality 50", async ({ request }) => { + const res = await request.post("/api/v1/tools/compress", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + settings: JSON.stringify({ quality: 50 }), + }, + }); + // HEIC compression may not be directly supported + if (res.ok()) { + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + } else { + expect([400, 422]).toContain(res.status()); + } + }); +}); + +// ─── Info: Every Format ─────────────────────────────────────────── + +test.describe("Info: JPEG", () => { + test("get info for JPEG returns correct dimensions", async ({ request }) => { + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.width).toBe(100); + expect(body.height).toBe(100); + expect(body.format).toBe("jpeg"); + expect(body.fileSize).toBeGreaterThan(0); + }); +}); + +test.describe("Info: PNG", () => { + test("get info for PNG returns correct dimensions", async ({ request }) => { + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.width).toBe(200); + expect(body.height).toBe(150); + expect(body.format).toBe("png"); + expect(body.fileSize).toBeGreaterThan(0); + }); +}); + +test.describe("Info: WebP", () => { + test("get info for WebP returns correct dimensions", async ({ request }) => { + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.webp", mimeType: "image/webp", buffer: WEBP_50x50 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.width).toBe(50); + expect(body.height).toBe(50); + expect(body.format).toBe("webp"); + expect(body.fileSize).toBeGreaterThan(0); + }); +}); + +test.describe("Info: AVIF", () => { + test("get info for AVIF returns dimensions and format", async ({ request }) => { + const avif = formatFixture("sample.avif"); + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.avif", mimeType: "image/avif", buffer: avif }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + expect(body.fileSize).toBeGreaterThan(0); + }); +}); + +test.describe("Info: HEIC", () => { + test("get info for HEIC returns dimensions", async ({ request }) => { + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + expect(body.fileSize).toBeGreaterThan(0); + }); +}); + +test.describe("Info: TIFF", () => { + test("get info for TIFF returns dimensions and format", async ({ request }) => { + const tiff = formatFixture("sample.tiff"); + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.tiff", mimeType: "image/tiff", buffer: tiff }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + expect(body.fileSize).toBeGreaterThan(0); + }); +}); + +test.describe("Info: GIF", () => { + test("get info for GIF returns dimensions and format", async ({ request }) => { + const gif = formatFixture("sample.gif"); + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.gif", mimeType: "image/gif", buffer: gif }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + expect(body.format).toBe("gif"); + expect(body.fileSize).toBeGreaterThan(0); + }); +}); + +test.describe("Info: HEIF", () => { + test("get info for HEIF returns dimensions", async ({ request }) => { + const heif = formatFixture("sample.heif"); + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.heif", mimeType: "image/heif", buffer: heif }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + expect(body.fileSize).toBeGreaterThan(0); + }); +}); + +test.describe("Info: BMP", () => { + test("get info for BMP returns dimensions if supported", async ({ request }) => { + const bmp = formatFixture("sample.bmp"); + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.bmp", mimeType: "image/bmp", buffer: bmp }, + }, + }); + if (res.ok()) { + const body = await res.json(); + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + expect(body.fileSize).toBeGreaterThan(0); + } else { + expect([400, 422]).toContain(res.status()); + } + }); +}); + +// ─── Cross-Format Conversions: Full Matrix ──────────────────────── + +test.describe("Cross-format: JPEG to all targets", () => { + test("JPEG to WebP", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ format: "webp" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".webp"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("JPEG to AVIF", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ format: "avif" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".avif"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("JPEG to TIFF", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ format: "tiff" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".tiff"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("JPEG to GIF", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ format: "gif" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".gif"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("JPEG to HEIC", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ format: "heic" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".heic"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Cross-format: PNG to all targets", () => { + test("PNG to JPEG", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "jpg", quality: 85 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".jpg"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("PNG to WebP", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "webp" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".webp"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("PNG to AVIF", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "avif" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".avif"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Cross-format: WebP to all targets", () => { + test("WebP to JPEG", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.webp", mimeType: "image/webp", buffer: WEBP_50x50 }, + settings: JSON.stringify({ format: "jpg" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".jpg"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("WebP to AVIF", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.webp", mimeType: "image/webp", buffer: WEBP_50x50 }, + settings: JSON.stringify({ format: "avif" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".avif"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("WebP to TIFF", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.webp", mimeType: "image/webp", buffer: WEBP_50x50 }, + settings: JSON.stringify({ format: "tiff" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".tiff"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +test.describe("Cross-format: HEIC to all targets", () => { + test("HEIC to JPEG", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + settings: JSON.stringify({ format: "jpg" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".jpg"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("HEIC to WebP", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + settings: JSON.stringify({ format: "webp" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".webp"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("HEIC to AVIF", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + settings: JSON.stringify({ format: "avif" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".avif"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("HEIC to TIFF", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + settings: JSON.stringify({ format: "tiff" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".tiff"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +// ─── Resize + Info Verification ─────────────────────────────────── + +test.describe("Resize + info verification: JPEG", () => { + test("resize JPEG then verify dimensions via info", async ({ request }) => { + // Step 1: Resize + const resizeRes = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ width: 50, height: 50, fit: "fill" }), + }, + }); + expect(resizeRes.ok()).toBe(true); + const resizeBody = await resizeRes.json(); + expect(resizeBody.downloadUrl).toBeTruthy(); + + // Step 2: Download resized image + const dlRes = await request.get(resizeBody.downloadUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(dlRes.ok()).toBe(true); + const resizedBuffer = Buffer.from(await dlRes.body()); + + // Step 3: Verify dimensions via info + const infoRes = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "resized.jpg", mimeType: "image/jpeg", buffer: resizedBuffer }, + }, + }); + expect(infoRes.ok()).toBe(true); + const infoBody = await infoRes.json(); + expect(infoBody.width).toBe(50); + expect(infoBody.height).toBe(50); + }); +}); + +test.describe("Resize + info verification: PNG", () => { + test("resize PNG then verify dimensions via info", async ({ request }) => { + const resizeRes = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ width: 100, fit: "contain" }), + }, + }); + expect(resizeRes.ok()).toBe(true); + const resizeBody = await resizeRes.json(); + + const dlRes = await request.get(resizeBody.downloadUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(dlRes.ok()).toBe(true); + const resizedBuffer = Buffer.from(await dlRes.body()); + + const infoRes = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "resized.png", mimeType: "image/png", buffer: resizedBuffer }, + }, + }); + expect(infoRes.ok()).toBe(true); + const infoBody = await infoRes.json(); + expect(infoBody.width).toBe(100); + // Aspect ratio preserved: 200x150 -> 100x75 + expect(infoBody.height).toBe(75); + expect(infoBody.format).toBe("png"); + }); +}); + +// ─── Convert + Info Verification ────────────────────────────────── + +test.describe("Convert + info verification", () => { + test("convert JPEG to WebP then verify format via info", async ({ request }) => { + const convertRes = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ format: "webp" }), + }, + }); + expect(convertRes.ok()).toBe(true); + const convertBody = await convertRes.json(); + + const dlRes = await request.get(convertBody.downloadUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(dlRes.ok()).toBe(true); + const convertedBuffer = Buffer.from(await dlRes.body()); + + const infoRes = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "converted.webp", mimeType: "image/webp", buffer: convertedBuffer }, + }, + }); + expect(infoRes.ok()).toBe(true); + const infoBody = await infoRes.json(); + expect(infoBody.format).toBe("webp"); + expect(infoBody.width).toBe(100); + expect(infoBody.height).toBe(100); + }); + + test("convert PNG to AVIF then verify format via info", async ({ request }) => { + const convertRes = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "avif" }), + }, + }); + expect(convertRes.ok()).toBe(true); + const convertBody = await convertRes.json(); + + const dlRes = await request.get(convertBody.downloadUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(dlRes.ok()).toBe(true); + const convertedBuffer = Buffer.from(await dlRes.body()); + + const infoRes = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "converted.avif", mimeType: "image/avif", buffer: convertedBuffer }, + }, + }); + expect(infoRes.ok()).toBe(true); + const infoBody = await infoRes.json(); + expect(infoBody.width).toBe(200); + expect(infoBody.height).toBe(150); + }); +}); diff --git a/tests/e2e-docker/pipeline-advanced.spec.ts b/tests/e2e-docker/pipeline-advanced.spec.ts new file mode 100644 index 00000000..1b618c0e --- /dev/null +++ b/tests/e2e-docker/pipeline-advanced.spec.ts @@ -0,0 +1,677 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +// ─── Pipeline Advanced ──────────────────────────────────────────── +// Multi-step pipeline chain tests with 3+ steps. Covers complex +// real-world workflows, duplicate steps, format changes mid-chain, +// and deep pipelines (5+ steps). + +const FIXTURES = join(process.cwd(), "tests", "fixtures"); +const FORMATS = join(FIXTURES, "formats"); + +let token: string; + +test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "admin" }, + }); + const body = await res.json(); + token = body.token; +}); + +function fixture(name: string): Buffer { + return readFileSync(join(FIXTURES, name)); +} + +function formatFixture(name: string): Buffer { + return readFileSync(join(FORMATS, name)); +} + +const PNG_200x150 = fixture("test-200x150.png"); +const JPG_100x100 = fixture("test-100x100.jpg"); +const HEIC_200x150 = fixture("test-200x150.heic"); +const JPG_SAMPLE = formatFixture("sample.jpg"); +const JPG_WITH_EXIF = fixture("test-with-exif.jpg"); +const WEBP_50x50 = fixture("test-50x50.webp"); + +// ─── 3-Step: Resize -> Compress -> Convert (JPEG to WebP) ──────── + +test.describe("3-step: resize -> compress -> convert", () => { + test("resize, compress, then convert JPEG to WebP", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 640, fit: "contain" } }, + { toolId: "compress", settings: { quality: 60 } }, + { toolId: "convert", settings: { format: "webp" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".webp"); + expect(body.processedSize).toBeGreaterThan(0); + expect(body.processedSize).toBeLessThan(body.originalSize); + }); + + test("resize, compress, then convert PNG to AVIF", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 100, fit: "contain" } }, + { toolId: "compress", settings: { quality: 50 } }, + { toolId: "convert", settings: { format: "avif" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".avif"); + }); +}); + +// ─── 4-Step: Rotate -> Resize -> Sharpening -> Compress ────────── + +test.describe("4-step: rotate -> resize -> sharpening -> compress", () => { + test("full 4-step image preparation pipeline", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "rotate", settings: { angle: 90 } }, + { toolId: "resize", settings: { width: 500, fit: "contain" } }, + { toolId: "sharpening", settings: { sigma: 1.5 } }, + { toolId: "compress", settings: { quality: 70 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("rotate 180 -> resize -> sharpen -> compress on PNG", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "rotate", settings: { angle: 180 } }, + { toolId: "resize", settings: { width: 150, height: 100, fit: "fill" } }, + { toolId: "sharpening", settings: { sigma: 2.0 } }, + { toolId: "compress", settings: { quality: 80 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── 5-Step: Strip Metadata -> Resize -> Adjust Colors -> Compress -> Convert ─ + +test.describe("5-step: strip-metadata -> resize -> adjust-colors -> compress -> convert", () => { + test("full 5-step processing pipeline on JPEG with EXIF", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "photo.jpg", mimeType: "image/jpeg", buffer: JPG_WITH_EXIF }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "strip-metadata", settings: {} }, + { toolId: "resize", settings: { width: 800, fit: "contain" } }, + { toolId: "adjust-colors", settings: { brightness: 10, contrast: 15, saturation: 5 } }, + { toolId: "compress", settings: { quality: 75 } }, + { toolId: "convert", settings: { format: "webp" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".webp"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("full 5-step pipeline on high-res sample image", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "strip-metadata", settings: {} }, + { toolId: "resize", settings: { width: 1200, fit: "contain" } }, + { toolId: "adjust-colors", settings: { brightness: -5, contrast: 10 } }, + { toolId: "compress", settings: { quality: 65 } }, + { toolId: "convert", settings: { format: "avif" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".avif"); + expect(body.processedSize).toBeLessThan(body.originalSize); + }); +}); + +// ─── Pipeline with Same Step Twice: Resize -> Resize ───────────── + +test.describe("Pipeline with same step twice", () => { + test("resize 200->100, then resize 100->50 (two sequential resizes)", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 100, fit: "contain" } }, + { toolId: "resize", settings: { width: 50, fit: "contain" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("resize with different fits: cover then fill", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 300, height: 300, fit: "cover" } }, + { toolId: "resize", settings: { width: 200, height: 150, fit: "fill" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("double compress with decreasing quality", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "compress", settings: { quality: 80 } }, + { toolId: "compress", settings: { quality: 30 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeLessThan(body.originalSize); + }); + + test("double sharpen with different sigma values", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "sharpening", settings: { sigma: 0.5 } }, + { toolId: "sharpening", settings: { sigma: 2.0 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Pipeline with Format Change Mid-Chain ──────────────────────── + +test.describe("Pipeline with format change mid-chain", () => { + test("convert JPEG to PNG, resize, then convert to WebP", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "convert", settings: { format: "png" } }, + { toolId: "resize", settings: { width: 400, fit: "contain" } }, + { toolId: "convert", settings: { format: "webp" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".webp"); + }); + + test("convert PNG to JPEG, enhance, then convert to AVIF", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "convert", settings: { format: "jpg", quality: 90 } }, + { toolId: "image-enhancement", settings: { preset: "auto" } }, + { toolId: "convert", settings: { format: "avif" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".avif"); + }); + + test("convert to TIFF mid-chain then back to WebP", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "convert", settings: { format: "tiff" } }, + { toolId: "resize", settings: { width: 80, fit: "contain" } }, + { toolId: "convert", settings: { format: "webp" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".webp"); + }); +}); + +// ─── HEIC Input Through Multi-Step Pipelines ────────────────────── + +test.describe("HEIC input through multi-step pipelines", () => { + test("HEIC: 3-step resize -> sharpen -> convert to PNG", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 100, fit: "contain" } }, + { toolId: "sharpening", settings: { sigma: 1.0 } }, + { toolId: "convert", settings: { format: "png" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".png"); + }); + + test("HEIC: 4-step adjust-colors -> resize -> border -> compress", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "adjust-colors", settings: { brightness: 15, contrast: 10 } }, + { toolId: "resize", settings: { width: 150, fit: "contain" } }, + { toolId: "border", settings: { size: 5, color: "#000000" } }, + { toolId: "compress", settings: { quality: 70 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +// ─── Deep Pipeline: 6+ Steps ────────────────────────────────────── + +test.describe("Deep pipelines (6+ steps)", () => { + test("7-step full processing pipeline", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "strip-metadata", settings: {} }, + { toolId: "rotate", settings: { angle: 90 } }, + { toolId: "resize", settings: { width: 800, fit: "contain" } }, + { toolId: "adjust-colors", settings: { brightness: 5, contrast: 10, saturation: -5 } }, + { toolId: "sharpening", settings: { sigma: 1.0 } }, + { + toolId: "watermark-text", + settings: { + text: "DEEP PIPELINE", + fontSize: 14, + color: "#808080", + opacity: 20, + position: "bottom-right", + }, + }, + { toolId: "compress", settings: { quality: 70 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + expect(body.processedSize).toBeLessThan(body.originalSize); + }); + + test("6-step pipeline with format change at the end", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "photo.jpg", mimeType: "image/jpeg", buffer: JPG_WITH_EXIF }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "strip-metadata", settings: {} }, + { toolId: "resize", settings: { width: 600, fit: "contain" } }, + { toolId: "adjust-colors", settings: { grayscale: true } }, + { toolId: "sharpening", settings: { sigma: 1.5 } }, + { toolId: "border", settings: { size: 8, color: "#ffffff" } }, + { toolId: "convert", settings: { format: "webp" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".webp"); + }); +}); + +// ─── Workflow: E-commerce Product Pipeline ──────────────────────── + +test.describe("Workflow: e-commerce product pipeline", () => { + test("crop -> resize -> enhance -> watermark -> compress -> convert", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "crop", settings: { left: 50, top: 50, width: 400, height: 400 } }, + { toolId: "resize", settings: { width: 800, height: 800, fit: "contain" } }, + { toolId: "image-enhancement", settings: { preset: "vivid" } }, + { + toolId: "watermark-text", + settings: { + text: "SAMPLE", + fontSize: 20, + color: "#cccccc", + opacity: 25, + position: "center", + }, + }, + { toolId: "compress", settings: { quality: 85 } }, + { toolId: "convert", settings: { format: "webp" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".webp"); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +// ─── Workflow: Blog Post Image Pipeline ─────────────────────────── + +test.describe("Workflow: blog post image pipeline", () => { + test("strip-metadata -> resize -> text-overlay -> optimize-for-web", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "photo.jpg", mimeType: "image/jpeg", buffer: JPG_WITH_EXIF }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "strip-metadata", settings: {} }, + { toolId: "resize", settings: { width: 1200, fit: "contain" } }, + { + toolId: "text-overlay", + settings: { + text: "Blog Header Image", + fontSize: 36, + color: "#ffffff", + position: "bottom", + backgroundBox: true, + backgroundColor: "#333333", + }, + }, + { toolId: "optimize-for-web", settings: { maxWidth: 1200, quality: 80 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +// ─── Workflow: Archive Preparation Pipeline ─────────────────────── + +test.describe("Workflow: archive preparation pipeline", () => { + test("strip-metadata -> adjust-colors -> resize -> convert to TIFF", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "strip-metadata", settings: {} }, + { toolId: "adjust-colors", settings: { brightness: 0, contrast: 5 } }, + { toolId: "resize", settings: { width: 2000, fit: "contain" } }, + { toolId: "convert", settings: { format: "tiff" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".tiff"); + }); +}); + +// ─── Pipeline Step Metadata Validation ──────────────────────────── + +test.describe("Pipeline step metadata validation", () => { + test("3-step pipeline returns step metadata if available", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 100, fit: "contain" } }, + { toolId: "sharpening", settings: { sigma: 1.0 } }, + { toolId: "compress", settings: { quality: 60 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + if (body.steps) { + expect(body.steps).toBeInstanceOf(Array); + expect(body.steps.length).toBe(3); + } + }); + + test("5-step pipeline returns step metadata if available", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "strip-metadata", settings: {} }, + { toolId: "resize", settings: { width: 500, fit: "contain" } }, + { toolId: "adjust-colors", settings: { brightness: 10 } }, + { toolId: "sharpening", settings: { sigma: 0.8 } }, + { toolId: "compress", settings: { quality: 70 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + if (body.steps) { + expect(body.steps).toBeInstanceOf(Array); + expect(body.steps.length).toBe(5); + } + }); +}); + +// ─── WebP Input Through Multi-Step Pipelines ────────────────────── + +test.describe("WebP input through multi-step pipelines", () => { + test("WebP: 3-step resize -> adjust-colors -> convert to PNG", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.webp", mimeType: "image/webp", buffer: WEBP_50x50 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 100, height: 100, fit: "fill" } }, + { toolId: "adjust-colors", settings: { brightness: 20, saturation: 15 } }, + { toolId: "convert", settings: { format: "png" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".png"); + }); +}); + +// ─── Pipeline with Enhancement + Border + Convert ───────────────── + +test.describe("Enhancement + Border + Convert pipeline", () => { + test("3-step enhance -> border -> convert to WebP", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "image-enhancement", settings: { preset: "auto" } }, + { toolId: "border", settings: { size: 10, color: "#333333" } }, + { toolId: "convert", settings: { format: "webp" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".webp"); + }); +}); + +// ─── Pipeline with Multiple Color Operations ────────────────────── + +test.describe("Pipeline with multiple color operations", () => { + test("adjust-colors -> replace-color -> adjust-colors (grayscale)", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "adjust-colors", settings: { brightness: 20, contrast: 10 } }, + { + toolId: "replace-color", + settings: { + targetColor: "#ffffff", + replacementColor: "#f0f0e0", + tolerance: 25, + }, + }, + { toolId: "adjust-colors", settings: { grayscale: true } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Pipeline with Crop After Rotate ────────────────────────────── + +test.describe("Pipeline with crop after rotate", () => { + test("rotate 90 -> crop center -> resize -> compress", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "rotate", settings: { angle: 90 } }, + { toolId: "crop", settings: { left: 20, top: 20, width: 200, height: 200 } }, + { toolId: "resize", settings: { width: 100, fit: "contain" } }, + { toolId: "compress", settings: { quality: 60 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/blur-faces.spec.ts b/tests/e2e/blur-faces.spec.ts index 022688ad..8e93afcf 100644 --- a/tests/e2e/blur-faces.spec.ts +++ b/tests/e2e/blur-faces.spec.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { expect, test } from "./helpers"; +import { expect, isAiSidecarRunning, test } from "./helpers"; function fixturePath(name: string): string { return path.join(process.cwd(), "tests", "fixtures", name); @@ -22,6 +22,9 @@ test.describe("Blur Faces tool", () => { } catch { test.skip(true, "face-detection feature bundle not installed"); } + if (!(await isAiSidecarRunning(page))) { + test.skip(true, "AI sidecar not running"); + } } test("page loads with correct UI controls", async ({ loggedInPage: page }) => { diff --git a/tests/e2e/colorize.spec.ts b/tests/e2e/colorize.spec.ts index 056bd8f0..756f1fce 100644 --- a/tests/e2e/colorize.spec.ts +++ b/tests/e2e/colorize.spec.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { expect, test } from "./helpers"; +import { expect, isAiSidecarRunning, test } from "./helpers"; function fixturePath(name: string): string { return path.join(process.cwd(), "tests", "fixtures", name); @@ -27,6 +27,9 @@ test.describe("Colorize tool", () => { } catch { test.skip(true, "object-eraser-colorize feature bundle not installed"); } + if (!(await isAiSidecarRunning(page))) { + test.skip(true, "AI sidecar not running"); + } } test("page loads with correct UI controls", async ({ loggedInPage: page }) => { diff --git a/tests/e2e/enhance-faces.spec.ts b/tests/e2e/enhance-faces.spec.ts index ab7dce25..df0c2f06 100644 --- a/tests/e2e/enhance-faces.spec.ts +++ b/tests/e2e/enhance-faces.spec.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { expect, test } from "./helpers"; +import { expect, isAiSidecarRunning, test } from "./helpers"; function fixturePath(name: string): string { return path.join(process.cwd(), "tests", "fixtures", name); @@ -27,6 +27,9 @@ test.describe("Enhance Faces tool", () => { } catch { test.skip(true, "upscale-enhance feature bundle not installed"); } + if (!(await isAiSidecarRunning(page))) { + test.skip(true, "AI sidecar not running"); + } } test("page loads with correct UI controls", async ({ loggedInPage: page }) => { diff --git a/tests/e2e/gui-accessibility.spec.ts b/tests/e2e/gui-accessibility.spec.ts new file mode 100644 index 00000000..f03c5ffe --- /dev/null +++ b/tests/e2e/gui-accessibility.spec.ts @@ -0,0 +1,354 @@ +import { expect, test } from "./helpers"; + +// --------------------------------------------------------------------------- +// GUI Accessibility: ARIA semantics, focus management, keyboard navigation +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Semantic HTML: Landmarks & Structure +// --------------------------------------------------------------------------- +test.describe("Semantic HTML - Landmarks", () => { + test("home page has exactly one main landmark", async ({ loggedInPage: page }) => { + await expect(page.locator("main")).toHaveCount(1); + }); + + test("tool page has exactly one main landmark", async ({ loggedInPage: page }) => { + await page.goto("/resize"); + await expect(page.locator("main")).toHaveCount(1); + }); + + test("home page has a sidebar landmark (aside)", async ({ loggedInPage: page }) => { + await expect(page.locator("aside")).toBeVisible(); + }); + + test("tool page has a sidebar landmark (aside)", async ({ loggedInPage: page }) => { + await page.goto("/resize"); + await expect(page.locator("aside")).toBeVisible(); + }); +}); + +test.describe("Semantic HTML - Buttons", () => { + test("all visible buttons on home page have accessible names", async ({ loggedInPage: page }) => { + // Wait for the page to fully load + await page.waitForLoadState("networkidle"); + + const buttons = page.getByRole("button"); + const count = await buttons.count(); + expect(count).toBeGreaterThan(0); + + for (let i = 0; i < count; i++) { + const button = buttons.nth(i); + if (!(await button.isVisible().catch(() => false))) continue; + + const name = await button.getAttribute("aria-label"); + const title = await button.getAttribute("title"); + const text = await button.textContent(); + + // Every visible button should have at least one of: text content, aria-label, or title + const hasAccessibleName = + (text && text.trim().length > 0) || + (name && name.trim().length > 0) || + (title && title.trim().length > 0); + expect( + hasAccessibleName, + `Button at index ${i} has no accessible name. text="${text}", aria-label="${name}", title="${title}"`, + ).toBeTruthy(); + } + }); + + test("all visible buttons on tool page have accessible names", async ({ loggedInPage: page }) => { + await page.goto("/resize"); + await page.waitForLoadState("networkidle"); + + const buttons = page.getByRole("button"); + const count = await buttons.count(); + expect(count).toBeGreaterThan(0); + + for (let i = 0; i < count; i++) { + const button = buttons.nth(i); + if (!(await button.isVisible().catch(() => false))) continue; + + const name = await button.getAttribute("aria-label"); + const title = await button.getAttribute("title"); + const text = await button.textContent(); + + const hasAccessibleName = + (text && text.trim().length > 0) || + (name && name.trim().length > 0) || + (title && title.trim().length > 0); + expect( + hasAccessibleName, + `Button at index ${i} has no accessible name. text="${text}", aria-label="${name}", title="${title}"`, + ).toBeTruthy(); + } + }); +}); + +test.describe("Semantic HTML - Form Inputs", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test("login form inputs have associated labels", async ({ page }) => { + await page.goto("/login"); + + // Username input should be findable by label + const usernameInput = page.getByLabel("Username"); + await expect(usernameInput).toBeVisible(); + await expect(usernameInput).toHaveAttribute("id", "username"); + + // Password input should be findable by label + const passwordInput = page.getByLabel("Password"); + await expect(passwordInput).toBeVisible(); + await expect(passwordInput).toHaveAttribute("id", "password"); + }); + + test("login form inputs have proper autocomplete attributes", async ({ page }) => { + await page.goto("/login"); + + await expect(page.getByLabel("Username")).toHaveAttribute("autocomplete", "username"); + await expect(page.getByLabel("Password")).toHaveAttribute("autocomplete", "current-password"); + }); +}); + +test.describe("Semantic HTML - Heading Hierarchy", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test("login page has sequential heading hierarchy", async ({ page }) => { + await page.goto("/login"); + + // Should have h1 (SnapOtter) and h2 (Login) + const h1 = page.locator("h1"); + const h2 = page.locator("h2"); + + await expect(h1.first()).toBeVisible(); + await expect(h2.first()).toBeVisible(); + + // h1 should appear before h2 in DOM order + const h1Box = await h1.first().boundingBox(); + const h2Box = await h2.first().boundingBox(); + expect(h1Box).toBeTruthy(); + expect(h2Box).toBeTruthy(); + if (h1Box && h2Box) { + expect(h1Box.y).toBeLessThan(h2Box.y); + } + }); +}); + +// --------------------------------------------------------------------------- +// Modal/Dialog Accessibility +// --------------------------------------------------------------------------- +test.describe("Settings Dialog Accessibility", () => { + test("settings dialog opens as a layered panel with backdrop", async ({ loggedInPage: page }) => { + await page.locator("aside").getByText("Settings").click(); + + // Dialog content should be visible + await expect(page.locator("h2").filter({ hasText: "Settings" })).toBeVisible(); + + // Backdrop should exist (the aria-hidden overlay) + const backdrop = page.locator("[aria-hidden='true']").first(); + await expect(backdrop).toBeVisible(); + }); + + test("Escape key closes settings dialog", async ({ loggedInPage: page }) => { + await page.locator("aside").getByText("Settings").click(); + await expect(page.locator("h2").filter({ hasText: "Settings" })).toBeVisible(); + + await page.keyboard.press("Escape"); + + await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible(); + }); + + test("settings dialog can be closed via close button", async ({ loggedInPage: page }) => { + await page.locator("aside").getByText("Settings").click(); + await expect(page.locator("h2").filter({ hasText: "Settings" })).toBeVisible(); + + // Use the X close button + const closeBtn = page + .locator("button") + .filter({ has: page.locator("svg.lucide-x") }) + .first(); + await closeBtn.click(); + + await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible(); + }); + + test("settings dialog has a close button", async ({ loggedInPage: page }) => { + await page.locator("aside").getByText("Settings").click(); + await expect(page.locator("h2").filter({ hasText: "Settings" })).toBeVisible(); + + // Close button with X icon + const closeBtn = page.locator("button").filter({ has: page.locator("svg.lucide-x") }); + await expect(closeBtn.first()).toBeVisible(); + + await closeBtn.first().click(); + await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible(); + }); +}); + +test.describe("Help Dialog Accessibility", () => { + test("Escape key closes help dialog", async ({ loggedInPage: page }) => { + await page.locator("aside").getByText("Help").click(); + await expect(page.getByRole("heading", { name: "Help" })).toBeVisible(); + + await page.keyboard.press("Escape"); + + await expect(page.getByRole("heading", { name: "Help" })).not.toBeVisible(); + }); + + test("help dialog closes via Escape and focus returns", async ({ loggedInPage: page }) => { + await page.locator("aside").getByText("Help").click(); + await expect(page.getByRole("heading", { name: "Help" })).toBeVisible(); + + await page.keyboard.press("Escape"); + + await expect(page.getByRole("heading", { name: "Help" })).not.toBeVisible(); + const activeElement = await page.evaluate(() => document.activeElement?.tagName); + expect(activeElement).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Dropzone Accessibility +// --------------------------------------------------------------------------- +test.describe("Dropzone Accessibility", () => { + test("dropzone has an accessible section label", async ({ loggedInPage: page }) => { + // The Dropzone component uses
+ const dropzone = page.locator("section[aria-label='File drop zone']"); + await expect(dropzone).toBeVisible(); + }); + + test("upload button inside dropzone is clickable", async ({ loggedInPage: page }) => { + // The upload button should be a real interactive element + const uploadBtn = page.getByRole("button", { name: /upload/i }).first(); + await expect(uploadBtn).toBeVisible(); + await expect(uploadBtn).toBeEnabled(); + }); +}); + +// --------------------------------------------------------------------------- +// Navigation Accessibility +// --------------------------------------------------------------------------- +test.describe("Navigation Accessibility", () => { + test("sidebar items are keyboard-navigable via Tab", async ({ loggedInPage: page }) => { + const sidebar = page.locator("aside"); + await expect(sidebar).toBeVisible(); + + // All sidebar items should be links or buttons (keyboard accessible) + const sidebarLinks = sidebar.locator("a"); + const sidebarButtons = sidebar.locator("button"); + + const linkCount = await sidebarLinks.count(); + const buttonCount = await sidebarButtons.count(); + + // Should have both navigation links and action buttons + expect(linkCount + buttonCount).toBeGreaterThanOrEqual(4); + }); + + test("sidebar links have href attributes for navigation", async ({ loggedInPage: page }) => { + const sidebar = page.locator("aside"); + const links = sidebar.locator("a"); + const count = await links.count(); + + for (let i = 0; i < count; i++) { + const href = await links.nth(i).getAttribute("href"); + expect(href).toBeTruthy(); + } + }); + + test("tool panel search input is focusable", async ({ loggedInPage: page }) => { + const searchInput = page.getByPlaceholder(/search/i).first(); + await expect(searchInput).toBeVisible(); + + await searchInput.focus(); + const isFocused = await page.evaluate(() => document.activeElement?.tagName === "INPUT"); + expect(isFocused).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// Focus Management +// --------------------------------------------------------------------------- +test.describe("Focus Management", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test("login page form elements are focusable in order", async ({ page }) => { + await page.goto("/login"); + + // Focus should be possible on username, password, and login button in order + const username = page.getByLabel("Username"); + const password = page.getByLabel("Password"); + const loginBtn = page.getByRole("button", { name: /login/i }); + + await username.focus(); + expect(await page.evaluate(() => document.activeElement?.id)).toBe("username"); + + await password.focus(); + expect(await page.evaluate(() => document.activeElement?.id)).toBe("password"); + + // Fill both fields to enable the button + await username.fill("test"); + await password.fill("test"); + + await loginBtn.focus(); + const activeTag = await page.evaluate(() => document.activeElement?.tagName); + expect(activeTag).toBe("BUTTON"); + }); +}); + +test.describe("Focus Management - Dialogs", () => { + test("closing settings dialog returns focus to page", async ({ loggedInPage: page }) => { + // Open settings + const settingsBtn = page.locator("aside").getByText("Settings"); + await settingsBtn.click(); + await expect(page.locator("h2").filter({ hasText: "Settings" })).toBeVisible(); + + // Close via Escape + await page.keyboard.press("Escape"); + await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible(); + + // Focus should return to the page body (not trapped in a removed dialog) + const activeElement = await page.evaluate(() => document.activeElement?.tagName); + expect(activeElement).toBeDefined(); + // Should not be null or stuck on a removed element + expect(activeElement).not.toBe("undefined"); + }); + + test("closing help dialog returns focus to page", async ({ loggedInPage: page }) => { + // Open help + const helpBtn = page.locator("aside").getByText("Help"); + await helpBtn.click(); + await expect(page.getByRole("heading", { name: "Help" })).toBeVisible(); + + // Close via Escape + await page.keyboard.press("Escape"); + await expect(page.getByRole("heading", { name: "Help" })).not.toBeVisible(); + + // Focus should return to the page + const activeElement = await page.evaluate(() => document.activeElement?.tagName); + expect(activeElement).toBeDefined(); + expect(activeElement).not.toBe("undefined"); + }); +}); + +// --------------------------------------------------------------------------- +// Connection Banner Accessibility +// --------------------------------------------------------------------------- +test.describe("Connection Banner Accessibility", () => { + test("connection banner uses role=status and aria-live=polite", async ({ + loggedInPage: page, + }) => { + // The ConnectionBanner component uses role="status" aria-live="polite" + // When connected, the banner is hidden. We verify the component is + // mounted by checking it does NOT show when connection is healthy. + // The role/aria-live attributes are in the source code for when it is visible. + + // On a healthy connection, the banner should not be visible + const banner = page.locator("[role='status'][aria-live='polite']"); + // The banner is only rendered when status !== "connected", so count may be 0 + const count = await banner.count(); + // Either not rendered (0) or rendered but hidden -- both are valid + expect(count).toBeGreaterThanOrEqual(0); + + // Page should still function normally + await expect(page.locator("main")).toBeVisible(); + }); +}); diff --git a/tests/e2e/gui-batch.spec.ts b/tests/e2e/gui-batch.spec.ts new file mode 100644 index 00000000..ea0d1087 --- /dev/null +++ b/tests/e2e/gui-batch.spec.ts @@ -0,0 +1,384 @@ +import path from "node:path"; +import { expect, test, waitForProcessing } from "./helpers"; + +// --------------------------------------------------------------------------- +// Helper: resolve fixture image paths +// --------------------------------------------------------------------------- +function getFixturePath(name: string): string { + return path.join(process.cwd(), "tests", "fixtures", name); +} + +const FIXTURE_JPG = getFixturePath("test-100x100.jpg"); +const FIXTURE_PNG = getFixturePath("test-200x150.png"); +const FIXTURE_WEBP = getFixturePath("test-50x50.webp"); + +// --------------------------------------------------------------------------- +// Multi-file upload tests +// --------------------------------------------------------------------------- +test.describe("Multi-file upload", () => { + test("upload 2 files via file chooser and both appear in Files section", async ({ + loggedInPage: page, + }) => { + await page.goto("/resize"); + + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([FIXTURE_JPG, FIXTURE_PNG]); + await page.waitForTimeout(1000); + + // Both files should be registered + await expect(page.getByText("Files (2)")).toBeVisible(); + }); + + test("upload 3+ files and all are listed with filenames and sizes", async ({ + loggedInPage: page, + }) => { + await page.goto("/resize"); + + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([FIXTURE_JPG, FIXTURE_PNG, FIXTURE_WEBP]); + await page.waitForTimeout(1000); + + // All 3 files should be registered + await expect(page.getByText("Files (3)")).toBeVisible(); + + // The currently selected file info should show a filename and size + await expect(page.getByText(/test-/i).first()).toBeVisible(); + await expect(page.getByText(/KB|B/i).first()).toBeVisible(); + }); + + test("'+ Add more' adds files to existing set", async ({ loggedInPage: page }) => { + await page.goto("/resize"); + + // Upload initial file + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([FIXTURE_JPG]); + await page.waitForTimeout(500); + + await expect(page.getByText("Files (1)")).toBeVisible(); + + // Click "+ Add more" which triggers a programmatic file input + const addMorePromise = page.waitForEvent("filechooser"); + await page.getByText("+ Add more").click(); + const addMoreChooser = await addMorePromise; + await addMoreChooser.setFiles([FIXTURE_PNG, FIXTURE_WEBP]); + await page.waitForTimeout(500); + + // Now should show 3 files + await expect(page.getByText("Files (3)")).toBeVisible(); + }); + + test("'Clear all' removes all files and returns to dropzone", async ({ loggedInPage: page }) => { + await page.goto("/resize"); + + // Upload files + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([FIXTURE_JPG, FIXTURE_PNG]); + await page.waitForTimeout(500); + + await expect(page.getByText("Files (2)")).toBeVisible(); + + // Clear all files + await page.getByText("Clear all").click(); + + // Dropzone should reappear + await expect(page.getByText("Upload from computer")).toBeVisible(); + }); + + test("ThumbnailStrip shows at bottom with clickable thumbnails", async ({ + loggedInPage: page, + }) => { + await page.goto("/resize"); + + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([FIXTURE_JPG, FIXTURE_PNG, FIXTURE_WEBP]); + await page.waitForTimeout(1000); + + // ThumbnailStrip renders when entries.length > 1 + // Each thumbnail is a