Files
SnapOtter/tests/e2e-docker/fixes-verification.spec.ts
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
2026-07-27 15:37:30 +08:00

454 lines
18 KiB
TypeScript

import path from "node:path";
import { expect, type Page, test } from "@playwright/test";
const FIXTURES_DIR = path.join(process.cwd(), "tests", "fixtures", "image", "valid");
function getFixture(name: string): string {
return path.join(FIXTURES_DIR, name);
}
async function uploadFiles(page: Page, filePaths: string[]): Promise<void> {
const fileChooserPromise = page.waitForEvent("filechooser");
const dropzone = page.locator("[class*='border-dashed']").first();
await dropzone.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(filePaths);
await page.waitForTimeout(3000);
}
async function waitForProcessingDone(page: Page, timeoutMs = 120_000): Promise<void> {
try {
const spinner = page.locator("[class*='animate-spin']");
if (await spinner.isVisible({ timeout: 3000 })) {
await spinner.waitFor({ state: "hidden", timeout: timeoutMs });
}
} catch {
// No spinner — processing may have been instant
}
await page.waitForTimeout(500);
}
type OcrQuality = "fast" | "balanced" | "best";
async function getAvailableOcrQualities(page: Page): Promise<OcrQuality[]> {
const data = await page.evaluate(async () => {
const token = window.localStorage.getItem("snapotter-token");
if (!token) throw new Error("Authenticated OCR test session has no API token");
const response = await fetch("/api/v1/features", {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) throw new Error(`Unable to read OCR capabilities: ${response.status}`);
return response.json();
});
const ocr = data.bundles?.find((bundle: { id: string }) => bundle.id === "ocr");
expect(ocr, "OCR feature state is missing").toBeDefined();
expect(ocr.availableQualities).toContain("fast");
return ocr.availableQualities as OcrQuality[];
}
async function verifyAccurateOcrTier(
page: Page,
quality: "balanced" | "best",
availableQualities: OcrQuality[],
): Promise<void> {
await page.getByRole("button", { name: new RegExp(quality, "i") }).click();
const processButton = page.getByTestId("ocr-submit");
if (!availableQualities.includes(quality)) {
await expect(page.getByText(/requires an additional download/i)).toBeVisible();
await expect(page.getByRole("button", { name: /enable ocr/i })).toBeVisible();
await expect(processButton).toBeDisabled();
return;
}
await expect(processButton).toBeEnabled();
await processButton.click();
await waitForProcessingDone(page, 120_000);
await expect(page.getByText(/extracted text/i)).toBeVisible({ timeout: 120_000 });
await expect(page.locator(".text-destructive-ink, .text-red-500")).toHaveCount(0);
}
// ─── 1. Error messages should never show [object Object] ─────────────
test.describe("Error message formatting", () => {
test("tool validation error shows readable message, not [object Object]", async ({ request }) => {
// Send a malformed request directly to trigger a validation error
const res = await request.post("/api/v1/tools/image/resize", {
multipart: {
file: {
name: "test.jpg",
mimeType: "image/jpeg",
buffer: Buffer.from("not-an-image"),
},
settings: JSON.stringify({ width: 0 }),
},
});
expect(res.ok()).toBe(false);
const body = await res.json();
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
expect(body.error).not.toContain("[object Object]");
if (body.details) {
expect(typeof body.details).toBe("string");
expect(body.details).not.toContain("[object Object]");
}
});
});
// ─── 2. Image-to-PDF auth fix ────────────────────────────────────────
test.describe("Image-to-PDF", () => {
test("single image converts to PDF without auth error", async ({ page }) => {
await page.goto("/image-to-pdf");
// image-to-pdf has its own upload UI — use the Upload button
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Upload from computer").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(getFixture("test-100x100.jpg"));
await page.waitForTimeout(3000);
const processBtn = page.getByRole("button", { name: /create pdf/i });
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
await processBtn.click();
await waitForProcessingDone(page);
// Should NOT see "Authentication required"
const errorEl = page.locator(".text-destructive-ink, .text-red-500, [class*='text-red']");
if (await errorEl.isVisible({ timeout: 3000 })) {
const text = await errorEl.textContent();
expect(text).not.toContain("Authentication required");
expect(text).not.toContain("[object Object]");
}
// Should see a download link
const downloadLink = page.locator("a[download], a[href*='download']");
await expect(downloadLink).toBeVisible({ timeout: 15_000 });
});
test("multiple images create multi-page PDF", async ({ page }) => {
await page.goto("/image-to-pdf");
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Upload from computer").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles([getFixture("test-100x100.jpg"), getFixture("test-200x150.png")]);
await page.waitForTimeout(3000);
const processBtn = page.getByRole("button", { name: /create pdf/i });
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
await processBtn.click();
await waitForProcessingDone(page);
const errorEl = page.locator(".text-destructive-ink, .text-red-500, [class*='text-red']");
if (await errorEl.isVisible({ timeout: 3000 })) {
const text = await errorEl.textContent();
expect(text).not.toContain("Authentication required");
}
const downloadLink = page.locator("a[download], a[href*='download']");
await expect(downloadLink).toBeVisible({ timeout: 15_000 });
});
});
// ─── 3. Split tool multi-file ────────────────────────────────────────
test.describe("Split tool", () => {
test("single image splits into tiles", async ({ page }) => {
await page.goto("/split");
await uploadFiles(page, [getFixture("test-200x150.png")]);
// Use 2x2 preset
await page.getByRole("button", { name: "2x2" }).click();
const processBtn = page.getByTestId("split-submit");
await expect(processBtn).toBeEnabled({ timeout: 5000 });
await processBtn.click();
await waitForProcessingDone(page, 30_000);
// Should see tile preview grid
const tiles = page.locator("button[title^='Download tile']");
await expect(tiles.first()).toBeVisible({ timeout: 10_000 });
const tileCount = await tiles.count();
expect(tileCount).toBe(4);
// ZIP download button should appear
await expect(page.getByRole("button", { name: /download all/i })).toBeVisible();
// No errors
const error = page.locator(".text-destructive-ink, .text-red-500");
expect(await error.isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
});
test("multiple images all get split with subfolders in ZIP", async ({ page }) => {
await page.goto("/split");
await uploadFiles(page, [getFixture("test-100x100.jpg"), getFixture("test-200x150.png")]);
await page.getByRole("button", { name: "2x2" }).click();
const processBtn = page.getByTestId("split-submit");
await expect(processBtn).toBeEnabled({ timeout: 5000 });
// Button should indicate multiple images
const btnText = await processBtn.textContent();
expect(btnText).toContain("2 Images");
await processBtn.click();
await waitForProcessingDone(page, 60_000);
// ZIP download should appear
await expect(page.getByRole("button", { name: /download all/i })).toBeVisible({
timeout: 15_000,
});
// Tile preview should show tiles from first image
const tiles = page.locator("button[title^='Download tile']");
await expect(tiles.first()).toBeVisible({ timeout: 10_000 });
// Summary should mention both images
const summary = page.locator("text=images split");
if (await summary.isVisible({ timeout: 2000 })) {
const text = await summary.textContent();
expect(text).toContain("2");
}
});
});
// ─── 4. Batch processing (tools that had single-file bug) ────────────
test.describe("Batch processing fixes", () => {
test("strip-metadata processes multiple files", async ({ page }) => {
await page.goto("/strip-metadata");
await uploadFiles(page, [getFixture("test-with-exif.jpg"), getFixture("test-100x100.jpg")]);
// There should be a thumbnail strip with 2 entries
await page.waitForTimeout(500);
const processBtn = page.getByRole("button", { name: /strip|remove|process/i });
await expect(processBtn).toBeEnabled({ timeout: 5000 });
await processBtn.click();
await waitForProcessingDone(page, 30_000);
// Should see ZIP download button for batch
const zipBtn = page.locator(
"button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')",
);
await expect(zipBtn).toBeVisible({ timeout: 15_000 });
});
test("blur-faces processes multiple files", async ({ page }) => {
await page.goto("/blur-faces");
await uploadFiles(page, [getFixture("multi-face.webp"), getFixture("portrait-color.jpg")]);
const processBtn = page.getByRole("button", { name: /blur|process/i });
await expect(processBtn).toBeEnabled({ timeout: 5000 });
await processBtn.click();
await waitForProcessingDone(page, 60_000);
// Should see batch ZIP
const zipBtn = page.locator(
"button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')",
);
await expect(zipBtn).toBeVisible({ timeout: 30_000 });
});
test("vectorize processes multiple files", async ({ page }) => {
await page.goto("/vectorize");
await uploadFiles(page, [getFixture("test-100x100.jpg"), getFixture("test-50x50.webp")]);
const processBtn = page.getByTestId("vectorize-submit");
await expect(processBtn).toBeEnabled({ timeout: 5000 });
await processBtn.click();
await waitForProcessingDone(page, 30_000);
// Should see ZIP download for batch
const zipBtn = page.locator(
"button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')",
);
await expect(zipBtn).toBeVisible({ timeout: 15_000 });
});
});
// ─── 5. Passport photo error handling ────────────────────────────────
test.describe("Passport photo", () => {
test("error message is readable, not [object Object]", async ({ page }) => {
await page.goto("/passport-photo");
// Upload a non-face image to trigger face detection failure
await uploadFiles(page, [getFixture("test-100x100.jpg")]);
// Wait for auto-analyze to run and potentially fail
await page.waitForTimeout(5000);
// Check for error message
const errorEl = page.locator(
".text-destructive-ink, .text-red-500, [class*='text-red'], [class*='error']",
);
if (await errorEl.isVisible({ timeout: 10_000 })) {
const text = await errorEl.textContent();
expect(text).not.toContain("[object Object]");
if (text && text.length > 0) {
expect(text.length).toBeGreaterThan(3);
}
}
});
test("passport photo works with real portrait", async ({ page }) => {
await page.goto("/passport-photo");
await uploadFiles(page, [getFixture("portrait-color.jpg")]);
// Wait for face analysis (uses MediaPipe + rembg, can be slow on CPU)
await page.waitForTimeout(5000);
const analyzeSpinner = page.locator("[class*='animate-spin']").first();
if (await analyzeSpinner.isVisible({ timeout: 3000 })) {
await analyzeSpinner.waitFor({ state: "hidden", timeout: 180_000 });
}
// Face detection should succeed — look for the Generate button
const generateBtn = page.getByRole("button", { name: /generate|create/i });
const analyzeError = page.locator("p.text-destructive-ink, .text-red-500");
const gotButton = await generateBtn.isVisible({ timeout: 10_000 }).catch(() => false);
const gotError = await analyzeError.isVisible({ timeout: 1000 }).catch(() => false);
if (gotError) {
const text = await analyzeError.textContent();
expect(text).not.toContain("[object Object]");
}
expect(gotButton || gotError).toBe(true);
});
});
// ─── 6. OCR modes ────────────────────────────────────────────────────
test.describe("OCR", () => {
test("fast mode renders a successful result without the optional pack", async ({ page }) => {
await page.goto("/ocr");
await uploadFiles(page, [getFixture("test-100x100.jpg")]);
// Select fast mode
const fastBtn = page.getByRole("button", { name: /fast/i });
if (await fastBtn.isVisible({ timeout: 2000 })) {
await fastBtn.click();
}
const processBtn = page.getByRole("button", { name: /extract|scan|process/i });
await expect(processBtn).toBeEnabled({ timeout: 5000 });
await processBtn.click();
await waitForProcessingDone(page, 60_000);
// A readable failure is still a failure. The official image contract is
// covered at the API level in ai-tools.spec.ts (including engine/provider);
// this GUI regression must reach the result state, even when the fixture
// legitimately contains no recognized text.
await expect(page.getByText(/extracted text/i)).toBeVisible({ timeout: 120_000 });
await expect(page.locator("p.text-destructive-ink, .text-red-500")).toHaveCount(0);
await expect(
page.getByTestId("ocr-result-text").or(page.getByText(/no text detected/i)),
).toBeVisible();
});
test("balanced mode works", async ({ page }) => {
await page.goto("/ocr");
await uploadFiles(page, [getFixture("test-100x100.jpg")]);
const availableQualities = await getAvailableOcrQualities(page);
await verifyAccurateOcrTier(page, "balanced", availableQualities);
});
test("best mode works", async ({ page }) => {
await page.goto("/ocr");
await uploadFiles(page, [getFixture("test-100x100.jpg")]);
const availableQualities = await getAvailableOcrQualities(page);
await verifyAccurateOcrTier(page, "best", availableQualities);
});
});
// ─── 7. Common tools still work (regression check) ──────────────────
test.describe("Regression checks", () => {
test("resize single image works", async ({ page }) => {
await page.goto("/resize");
await uploadFiles(page, [getFixture("test-200x150.png")]);
// Set explicit width so the button enables
const widthInput = page.getByLabel("Width (px)");
await widthInput.fill("100");
const processBtn = page.getByTestId("resize-submit");
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
await processBtn.click();
await waitForProcessingDone(page);
const error = page.locator(".text-destructive-ink, .text-red-500");
expect(await error.isVisible({ timeout: 2000 }).catch(() => false)).toBe(false);
});
test("compress single image works", async ({ page }) => {
await page.goto("/compress");
await uploadFiles(page, [getFixture("test-100x100.jpg")]);
const processBtn = page.getByRole("button", { name: /compress/i });
await expect(processBtn).toBeEnabled({ timeout: 5000 });
await processBtn.click();
await waitForProcessingDone(page);
const error = page.locator(".text-destructive-ink, .text-red-500");
expect(await error.isVisible({ timeout: 2000 }).catch(() => false)).toBe(false);
});
test("resize batch processes multiple files", async ({ page }) => {
await page.goto("/resize");
await uploadFiles(page, [getFixture("test-100x100.jpg"), getFixture("test-200x150.png")]);
// Set explicit width so the button enables
const widthInput = page.getByLabel("Width (px)");
await widthInput.fill("50");
const processBtn = page.getByTestId("resize-submit");
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
await processBtn.click();
await waitForProcessingDone(page, 30_000);
// Batch should produce ZIP
const zipBtn = page.locator(
"button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')",
);
await expect(zipBtn).toBeVisible({ timeout: 15_000 });
});
});
// ─── 8. Docker container health and logging ──────────────────────────
test.describe("Container health", () => {
test("health endpoint returns healthy", async ({ request }) => {
const res = await request.get("/api/v1/health");
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.status).toBe("healthy");
});
test("API returns structured errors, not HTML", async ({ request }) => {
const res = await request.get("/api/v1/tools/nonexistent");
const contentType = res.headers()["content-type"] ?? "";
expect(contentType).toContain("application/json");
});
test("error responses have string details, not objects", async ({ request }) => {
const formData = new URLSearchParams();
const res = await request.post("/api/v1/tools/image/resize", {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
data: formData.toString(),
});
expect(res.ok()).toBe(false);
const body = await res.json();
if (body.details) {
expect(typeof body.details).toBe("string");
}
if (body.error) {
expect(typeof body.error).toBe("string");
}
});
});