Files
SnapOtter/tests/e2e-docker/pipeline-chains.spec.ts
T
SnapOtter 7f62bc32db test: expand coverage to 3,382 tests across all layers
- Unit: 1,353 tests (42 files) — +256 new tests covering AI bridge
  modules, image-engine sharpen/optimize-for-web, Zustand stores, and
  icon-map validation
- Integration: 1,640 tests (57 files) — +826 new tests across all
  tool routes, pipeline/progress/batch infrastructure, user-files,
  edit-metadata, and a 321-test cross-format matrix
- E2E-Docker: 389 passing (20 spec files) — 6 new spec files for
  batch processing, format conversion, layout, optimization,
  watermark/overlay, and pipeline chains. Tests verified against fresh
  Docker container with all 6 AI bundles installed.

Bug fixes discovered during testing:
- fix(compress): SVG/BMP/exotic formats crashed Sharp encoder — added
  format-safety fallback to PNG
- fix(rate-limit): increase default login attempt limit from 10 to 500
  per minute — previous value caused false test failures and is too
  restrictive for a self-hosted app
- fix(auth.setup): wait for consent button visibility before clicking
  to prevent flaky E2E-Docker auth setup
2026-04-24 22:43:14 +08:00

516 lines
19 KiB
TypeScript

import { readFileSync } from "node:fs";
import { join } from "node:path";
import { expect, test } from "@playwright/test";
// ─── Pipeline Chains ───────────────────────────────────────────────
// Multi-step pipeline chain tests: combining different tool categories
// in realistic workflows. Complements pipeline-tools.spec.ts with
// more complex chains and cross-category combinations.
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");
// ─── Resize + Watermark + Optimize Chain ───────────────────────────
test.describe("Resize + Watermark + Optimize chain", () => {
test("resize then text watermark then optimize for web", 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: 800, fit: "contain" } },
{
toolId: "watermark-text",
settings: {
text: "PREVIEW",
fontSize: 32,
color: "#ffffff",
opacity: 35,
position: "center",
},
},
{ toolId: "optimize-for-web", settings: { maxWidth: 800, quality: 75 } },
],
}),
},
});
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);
});
});
// ─── Crop + Enhance + Convert Chain ────────────────────────────────
test.describe("Crop + Enhance + Convert chain", () => {
test("crop then enhance 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: "crop", settings: { left: 10, top: 10, width: 200, height: 200 } },
{ toolId: "image-enhancement", settings: { preset: "auto" } },
{ toolId: "convert", settings: { format: "webp" } },
],
}),
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.downloadUrl).toBeTruthy();
expect(body.downloadUrl).toContain(".webp");
});
});
// ─── Strip Metadata + Resize + Compress Chain ──────────────────────
test.describe("Strip Metadata + Resize + Compress chain", () => {
test("strip metadata then resize then compress", 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: 400, 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);
});
});
// ─── Color Adjust + Sharpen + Border Chain ─────────────────────────
test.describe("Color Adjust + Sharpen + Border chain", () => {
test("adjust colors then sharpen then add border", 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: 10, contrast: 20 } },
{ toolId: "sharpening", settings: { sigma: 1.0 } },
{ toolId: "border", settings: { size: 10, color: "#333333" } },
],
}),
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.downloadUrl).toBeTruthy();
});
});
// ─── Rotate + Crop + Watermark Chain ───────────────────────────────
test.describe("Rotate + Crop + Watermark chain", () => {
test("rotate then crop then watermark text", 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: 0, top: 0, width: 300, height: 300 } },
{
toolId: "watermark-text",
settings: {
text: "ROTATED",
fontSize: 24,
color: "#ff0000",
opacity: 50,
position: "bottom-right",
},
},
],
}),
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.downloadUrl).toBeTruthy();
});
});
// ─── Enhancement + Replace Color + Convert Chain ───────────────────
test.describe("Enhancement + Replace Color + Convert chain", () => {
test("enhance then replace color then convert to AVIF", 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: "vivid" } },
{
toolId: "replace-color",
settings: {
targetColor: "#ffffff",
replacementColor: "#f0f0f0",
tolerance: 20,
},
},
{ toolId: "convert", settings: { format: "avif" } },
],
}),
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.downloadUrl).toBeTruthy();
expect(body.downloadUrl).toContain(".avif");
});
});
// ─── HEIC Pipeline Chains ──────────────────────────────────────────
test.describe("HEIC pipeline chains", () => {
test("HEIC: resize then 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: "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: enhance then watermark then 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: "image-enhancement", settings: { preset: "auto" } },
{
toolId: "watermark-text",
settings: {
text: "HEIC PIPELINE",
fontSize: 16,
color: "#808080",
opacity: 30,
position: "bottom-right",
},
},
{ toolId: "compress", settings: { quality: 70 } },
],
}),
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.downloadUrl).toBeTruthy();
});
});
// ─── Grayscale + Sharpen + Text Overlay Chain ──────────────────────
test.describe("Grayscale + Sharpen + Text Overlay chain", () => {
test("grayscale then sharpen then text overlay", 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: "adjust-colors", settings: { grayscale: true } },
{ toolId: "sharpening", settings: { sigma: 2.0 } },
{
toolId: "text-overlay",
settings: {
text: "B&W",
fontSize: 48,
color: "#FFFFFF",
position: "bottom",
backgroundBox: true,
backgroundColor: "#000000",
},
},
],
}),
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.downloadUrl).toBeTruthy();
});
});
// ─── Social Media Optimization Chain ───────────────────────────────
test.describe("Social media optimization chain", () => {
test("resize for Instagram then enhance then watermark", 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: 1080, height: 1080, fit: "cover" } },
{ toolId: "image-enhancement", settings: { preset: "vivid" } },
{
toolId: "watermark-text",
settings: {
text: "@snapotter",
fontSize: 16,
color: "#ffffff",
opacity: 30,
position: "bottom-right",
},
},
{ toolId: "compress", settings: { quality: 85 } },
],
}),
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.downloadUrl).toBeTruthy();
expect(body.processedSize).toBeGreaterThan(0);
});
});
// ─── Print Preparation Chain ───────────────────────────────────────
test.describe("Print preparation chain", () => {
test("strip metadata then resize for A4 then sharpen then border", 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: 2480, fit: "contain" } },
{ toolId: "sharpening", settings: { sigma: 1.2 } },
{ toolId: "border", settings: { size: 40, color: "#ffffff" } },
],
}),
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.downloadUrl).toBeTruthy();
});
});
// ─── Thumbnail Generation Chain ────────────────────────────────────
test.describe("Thumbnail generation chain", () => {
test("resize small then enhance 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: "resize", settings: { width: 150, height: 150, fit: "cover" } },
{ toolId: "sharpening", settings: { sigma: 0.5 } },
{ 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);
// Thumbnail should be much smaller than original
expect(body.processedSize).toBeLessThan(body.originalSize);
});
});
// ─── Edit Metadata + Convert Chain ─────────────────────────────────
test.describe("Edit Metadata + Convert chain", () => {
test("edit metadata then convert to PNG", 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: "edit-metadata",
settings: { artist: "Pipeline Test", copyright: "CC0" },
},
{ 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 Chain Validation ─────────────────────────────────────
test.describe("Pipeline chain validation", () => {
test("pipeline with duplicate steps succeeds", 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: 150, fit: "contain" } },
{ toolId: "resize", settings: { width: 100, fit: "contain" } },
],
}),
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.downloadUrl).toBeTruthy();
});
test("pipeline with 6 steps completes", 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: 600, fit: "contain" } },
{ toolId: "adjust-colors", settings: { brightness: 5, contrast: 10 } },
{ toolId: "sharpening", settings: { sigma: 0.8 } },
{
toolId: "watermark-text",
settings: {
text: "6-step",
fontSize: 14,
color: "#808080",
opacity: 20,
position: "bottom-right",
},
},
{ toolId: "compress", settings: { quality: 75 } },
],
}),
},
});
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("pipeline step metadata is returned", 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: "border", settings: { size: 5, color: "#000000" } },
],
}),
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.downloadUrl).toBeTruthy();
// Pipeline should return step metadata
if (body.steps) {
expect(body.steps).toBeInstanceOf(Array);
expect(body.steps.length).toBe(2);
}
});
test("saved pipeline can be executed", async ({ request }) => {
// Save a pipeline
const saveRes = await request.post("/api/v1/pipeline/save", {
headers: { Authorization: `Bearer ${token}` },
data: {
name: "Chain Test Pipeline",
description: "Resize and convert for chain testing",
steps: [
{ toolId: "resize", settings: { width: 200, fit: "contain" } },
{ toolId: "convert", settings: { format: "webp" } },
],
},
});
expect(saveRes.ok()).toBe(true);
const { id } = await saveRes.json();
// Execute the saved pipeline
const execRes = 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: 200, fit: "contain" } },
{ toolId: "convert", settings: { format: "webp" } },
],
}),
},
});
expect(execRes.ok()).toBe(true);
const execBody = await execRes.json();
expect(execBody.downloadUrl).toBeTruthy();
expect(execBody.downloadUrl).toContain(".webp");
// Clean up
await request.delete(`/api/v1/pipeline/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
});
});