Files
SnapOtter/tests/e2e/gui-tools-essential.spec.ts
T
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

992 lines
38 KiB
TypeScript

import path from "node:path";
import { expect, test, uploadTestImage, waitForProcessing } from "./helpers";
// ---------------------------------------------------------------------------
// GUI E2E: Essential Tools (resize, crop, rotate, convert, compress)
// Covers settings UI, control interactions, processing flow, and download.
// ---------------------------------------------------------------------------
test.describe("GUI Essential Tools", () => {
// ========================================================================
// RESIZE
// ========================================================================
test.describe("Resize", () => {
test("renders tool page with dropzone", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await expect(page.getByText("Resize").first()).toBeVisible();
await expect(page.getByText("Upload from computer")).toBeVisible();
});
test("shows custom size tab with width/height inputs after upload", async ({
loggedInPage: page,
}) => {
await page.goto("/image/resize");
await uploadTestImage(page);
// Custom Size tab is active by default
await expect(page.getByText("Custom Size")).toBeVisible();
await expect(page.locator("#resize-width")).toBeVisible();
await expect(page.locator("#resize-height")).toBeVisible();
});
test("tab switching between Custom Size, Scale, and Presets", async ({
loggedInPage: page,
}) => {
await page.goto("/image/resize");
await uploadTestImage(page);
// Switch to Scale tab
await page.getByText("Scale").click();
await expect(page.locator("#resize-scale")).toBeVisible();
// Verify percentage quick buttons
await expect(page.getByRole("button", { name: "25%" })).toBeVisible();
await expect(page.getByRole("button", { name: "50%" })).toBeVisible();
await expect(page.getByRole("button", { name: "75%" })).toBeVisible();
// Switch to Presets tab
await page.getByText("Presets").click();
await expect(page.getByText("Instagram").first()).toBeVisible();
// Switch back to Custom Size
await page.getByText("Custom Size").click();
await expect(page.locator("#resize-width")).toBeVisible();
});
test("fit mode buttons work in custom tab", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await expect(page.getByText("Fit Mode")).toBeVisible();
await expect(page.getByText("Crop to fit")).toBeVisible();
await expect(page.getByText("Fit inside")).toBeVisible();
await expect(page.getByText("Stretch")).toBeVisible();
// Click Fit inside
await page.getByText("Fit inside").click();
});
test("content-aware tab reveals advanced options", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
// Content-aware is a settings tab (presets / custom / scale / content-aware)
await page.getByRole("button", { name: "Content-Aware" }).click();
// Advanced options should appear
await expect(page.getByText("Resize to square")).toBeVisible();
await expect(page.getByText("Protect faces")).toBeVisible();
await expect(page.getByText("Smoothing")).toBeVisible();
await expect(page.getByText("Edge sensitivity")).toBeVisible();
});
test("submit disabled without dimensions, enabled with width", async ({
loggedInPage: page,
}) => {
await page.goto("/image/resize");
await uploadTestImage(page);
// Submit should be disabled without dimensions
const submitBtn = page.getByTestId("resize-submit");
await expect(submitBtn).toBeDisabled();
// Set width
await page.locator("#resize-width").fill("50");
await expect(submitBtn).toBeEnabled();
});
test("processes image and shows download link", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await page.locator("#resize-width").fill("50");
await page.getByTestId("resize-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("resize-download")).toBeVisible({ timeout: 15_000 });
});
test("scale tab processes image at 25%", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await page.getByText("Scale").click();
await page.getByRole("button", { name: "25%" }).click();
await page.getByTestId("resize-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("resize-download")).toBeVisible({ timeout: 15_000 });
});
test("aspect ratio link button toggles", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
// The link/unlink button for aspect ratio should be visible
const linkBtn = page.locator("button[title*='aspect']").first();
if (await linkBtn.isVisible()) {
await linkBtn.click();
}
});
test("download link has correct data-testid", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await page.locator("#resize-width").fill("50");
await page.getByTestId("resize-submit").click();
await waitForProcessing(page);
const downloadLink = page.getByTestId("resize-download");
await expect(downloadLink).toBeVisible({ timeout: 15_000 });
await expect(downloadLink).toHaveText(/Download/);
});
test("width and height inputs accept numeric values", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await page.locator("#resize-width").fill("200");
await expect(page.locator("#resize-width")).toHaveValue("200");
});
test("scale percentage quick buttons are interactive", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await page.getByText("Scale").click();
await page.getByRole("button", { name: "50%" }).click();
// Scale value should update
await expect(page.locator("#resize-scale")).toHaveValue("50");
});
});
// ========================================================================
// CROP
// ========================================================================
test.describe("Crop", () => {
test("renders tool page with dropzone", async ({ loggedInPage: page }) => {
await page.goto("/image/crop");
await expect(page.getByText("Crop").first()).toBeVisible();
await expect(page.getByText("Upload from computer")).toBeVisible();
});
test("shows aspect ratio presets after upload", async ({ loggedInPage: page }) => {
await page.goto("/image/crop");
await uploadTestImage(page);
await expect(page.getByText("Aspect Ratio")).toBeVisible();
// Verify preset buttons
await expect(page.getByRole("button", { name: "Free" })).toBeVisible();
await expect(page.getByRole("button", { name: "1:1" })).toBeVisible();
await expect(page.getByRole("button", { name: "4:3" })).toBeVisible();
await expect(page.getByRole("button", { name: "16:9" })).toBeVisible();
await expect(page.getByRole("button", { name: "Custom" })).toBeVisible();
});
test("shows position and size inputs after upload", async ({ loggedInPage: page }) => {
await page.goto("/image/crop");
await uploadTestImage(page);
await expect(page.getByText("Position & Size")).toBeVisible();
await expect(page.locator("#crop-x")).toBeVisible();
await expect(page.locator("#crop-y")).toBeVisible();
await expect(page.locator("#crop-width")).toBeVisible();
await expect(page.locator("#crop-height")).toBeVisible();
});
test("custom aspect ratio reveals input fields", async ({ loggedInPage: page }) => {
await page.goto("/image/crop");
await uploadTestImage(page);
await page.getByRole("button", { name: "Custom" }).click();
// Custom mode shows two number inputs for ratio
const customInputs = page.locator("input[type='number']");
await expect(customInputs.first()).toBeVisible();
});
test("rule of thirds grid toggle", async ({ loggedInPage: page }) => {
await page.goto("/image/crop");
await uploadTestImage(page);
await expect(page.getByText("Rule of Thirds")).toBeVisible();
});
test("crop submit button uses data-testid", async ({ loggedInPage: page }) => {
await page.goto("/image/crop");
await uploadTestImage(page);
await page.waitForTimeout(500);
await expect(page.getByTestId("crop-submit")).toBeVisible();
});
test("aspect ratio presets change the active button", async ({ loggedInPage: page }) => {
await page.goto("/image/crop");
await uploadTestImage(page);
// Click 1:1 aspect ratio
await page.getByRole("button", { name: "1:1" }).click();
// Click 16:9
await page.getByRole("button", { name: "16:9" }).click();
// Switch back to Free
await page.getByRole("button", { name: "Free" }).click();
});
test("processes crop and shows download", async ({ loggedInPage: page }) => {
await page.goto("/image/crop");
await uploadTestImage(page);
await page.waitForTimeout(1000);
// Set crop dimensions via number inputs
const widthInputs = page.locator("input[type='number']");
if ((await widthInputs.count()) >= 4) {
await widthInputs.nth(2).fill("50");
await widthInputs.nth(3).fill("50");
}
await page.getByTestId("crop-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("crop-download")).toBeVisible({ timeout: 15_000 });
});
test("tall portrait image (200x4000) fits within viewport without overflow", async ({
loggedInPage: page,
}) => {
await page.goto("/image/crop");
const portraitPath = path.join(
process.cwd(),
"tests",
"fixtures",
"image",
"edge",
"test-portrait-tall.png",
);
const fileChooserPromise = page.waitForEvent("filechooser");
await page.locator("[class*='border-dashed']").first().click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(portraitPath);
await page.waitForTimeout(1000);
const img = page.locator(".ReactCrop img");
await expect(img).toBeVisible();
const viewport = page.viewportSize();
expect(viewport).not.toBeNull();
const box = await img.boundingBox();
expect(box).not.toBeNull();
if (box && viewport) {
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
expect(box.y).toBeGreaterThanOrEqual(0);
}
});
test("extremely tall portrait image (100x6000) fits within viewport without overflow", async ({
loggedInPage: page,
}) => {
await page.goto("/image/crop");
const extremePath = path.join(
process.cwd(),
"tests",
"fixtures",
"image",
"edge",
"test-portrait-extreme.png",
);
const fileChooserPromise = page.waitForEvent("filechooser");
await page.locator("[class*='border-dashed']").first().click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(extremePath);
await page.waitForTimeout(1000);
const img = page.locator(".ReactCrop img");
await expect(img).toBeVisible();
const viewport = page.viewportSize();
expect(viewport).not.toBeNull();
const box = await img.boundingBox();
expect(box).not.toBeNull();
if (box && viewport) {
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
expect(box.y).toBeGreaterThanOrEqual(0);
}
});
});
// ========================================================================
// ROTATE
// ========================================================================
test.describe("Rotate", () => {
test("renders tool page with dropzone", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await expect(page.getByText("Rotate").first()).toBeVisible();
await expect(page.getByText("Upload from computer")).toBeVisible();
});
test("shows rotate controls after upload", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
// Quick rotate buttons
await expect(page.getByTestId("rotate-left")).toBeVisible();
await expect(page.getByTestId("rotate-right")).toBeVisible();
await expect(page.getByRole("button", { name: "180" })).toBeVisible();
});
test("shows flip buttons", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
await expect(page.getByText("Flip").first()).toBeVisible();
await expect(page.getByTestId("rotate-flip-h")).toBeVisible();
await expect(page.getByTestId("rotate-flip-v")).toBeVisible();
});
test("shows straighten slider", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
await expect(page.getByText("Straighten")).toBeVisible();
await expect(page.locator("#rotate-straighten")).toBeVisible();
});
test("angle input updates on rotate-right click", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
await page.getByTestId("rotate-right").click();
await expect(page.locator("input[inputmode='numeric']")).toHaveValue("90", {
timeout: 2000,
});
});
test("submit disabled without changes, enabled after rotation", async ({
loggedInPage: page,
}) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
// Submit should be disabled with no changes
const submitBtn = page.getByTestId("rotate-submit");
await expect(submitBtn).toBeDisabled();
// Rotate 90 degrees
await page.getByTestId("rotate-right").click();
await expect(submitBtn).toBeEnabled();
});
test("reset all changes button appears after modification", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
await page.getByTestId("rotate-right").click();
await expect(page.getByText("Reset all changes")).toBeVisible();
});
test("flip horizontal enables submit", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
const submitBtn = page.getByTestId("rotate-submit");
await expect(submitBtn).toBeDisabled();
await page.getByTestId("rotate-flip-h").click();
await expect(submitBtn).toBeEnabled();
});
test("flip vertical enables submit", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
const submitBtn = page.getByTestId("rotate-submit");
await expect(submitBtn).toBeDisabled();
await page.getByTestId("rotate-flip-v").click();
await expect(submitBtn).toBeEnabled();
});
test("180 degree button sets correct angle", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
await page.getByRole("button", { name: "180" }).click();
await expect(page.locator("input[inputmode='numeric']")).toHaveValue("180", {
timeout: 2000,
});
});
test("straighten slider is interactive", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
const slider = page.locator("#rotate-straighten");
await expect(slider).toBeVisible();
await expect(slider).toHaveAttribute("type", "range");
});
test("processes rotation and shows result", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
await page.getByTestId("rotate-right").click();
await page.getByTestId("rotate-submit").click();
await waitForProcessing(page);
await expect(page.locator("[data-download-button]").first()).toBeVisible({ timeout: 15_000 });
});
});
// ========================================================================
// CONVERT
// ========================================================================
test.describe("Convert", () => {
test("renders tool page with dropzone", async ({ loggedInPage: page }) => {
await page.goto("/image/convert");
await expect(page.getByText("Convert").first()).toBeVisible();
await expect(page.getByText("Upload from computer")).toBeVisible();
});
test("shows source format and target format selector after upload", async ({
loggedInPage: page,
}) => {
await page.goto("/image/convert");
await uploadTestImage(page);
await expect(page.getByText("Source Format")).toBeVisible();
await expect(page.locator("#convert-target-format")).toBeVisible();
});
test("format selector contains all output formats", async ({ loggedInPage: page }) => {
await page.goto("/image/convert");
await uploadTestImage(page);
const select = page.locator("#convert-target-format");
const options = select.locator("option");
// jpg, png, webp, avif, tiff, gif, heic, heif, jxl, bmp, ico, jp2, qoi,
// ppm, eps, tga (OUTPUT_FORMATS in convert-settings.tsx)
await expect(options).toHaveCount(16);
});
test("quality slider appears for lossy formats", async ({ loggedInPage: page }) => {
await page.goto("/image/convert");
await uploadTestImage(page);
// Select JPG (lossy)
await page.selectOption("#convert-target-format", "jpg");
await expect(page.locator("#convert-quality")).toBeVisible();
// Switch to PNG (lossless) - quality should disappear
await page.selectOption("#convert-target-format", "png");
await expect(page.locator("#convert-quality")).not.toBeVisible();
});
test("quality slider appears for WebP format", async ({ loggedInPage: page }) => {
await page.goto("/image/convert");
await uploadTestImage(page);
await page.selectOption("#convert-target-format", "webp");
await expect(page.locator("#convert-quality")).toBeVisible();
});
test("quality slider appears for AVIF format", async ({ loggedInPage: page }) => {
await page.goto("/image/convert");
await uploadTestImage(page);
await page.selectOption("#convert-target-format", "avif");
await expect(page.locator("#convert-quality")).toBeVisible();
});
test("submit button uses data-testid", async ({ loggedInPage: page }) => {
await page.goto("/image/convert");
await uploadTestImage(page);
await expect(page.getByTestId("convert-submit")).toBeVisible();
});
test("quality slider hidden for TIFF lossless format", async ({ loggedInPage: page }) => {
await page.goto("/image/convert");
await uploadTestImage(page);
await page.selectOption("#convert-target-format", "tiff");
await expect(page.locator("#convert-quality")).not.toBeVisible();
});
test("changing format resets quality slider visibility", async ({ loggedInPage: page }) => {
await page.goto("/image/convert");
await uploadTestImage(page);
// Start with JPG (lossy, shows quality)
await page.selectOption("#convert-target-format", "jpg");
await expect(page.locator("#convert-quality")).toBeVisible();
// Switch to PNG (lossless, hides quality)
await page.selectOption("#convert-target-format", "png");
await expect(page.locator("#convert-quality")).not.toBeVisible();
// Switch back to WebP (lossy, shows quality)
await page.selectOption("#convert-target-format", "webp");
await expect(page.locator("#convert-quality")).toBeVisible();
});
test("processes conversion and shows download", async ({ loggedInPage: page }) => {
await page.goto("/image/convert");
await uploadTestImage(page);
await page.selectOption("#convert-target-format", "webp");
await page.getByTestId("convert-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("convert-download")).toBeVisible({ timeout: 15_000 });
});
});
// ========================================================================
// COMPRESS
// ========================================================================
test.describe("Compress", () => {
test("renders tool page with dropzone", async ({ loggedInPage: page }) => {
await page.goto("/image/compress");
await expect(page.getByText("Compress").first()).toBeVisible();
await expect(page.getByText("Upload from computer")).toBeVisible();
});
test("shows compression mode toggle after upload", async ({ loggedInPage: page }) => {
await page.goto("/image/compress");
await uploadTestImage(page);
await expect(page.getByText("Compression Mode")).toBeVisible();
await expect(page.getByRole("button", { name: "Quality" })).toBeVisible();
await expect(page.getByRole("button", { name: "Target Size" })).toBeVisible();
});
test("quality mode shows quality slider", async ({ loggedInPage: page }) => {
await page.goto("/image/compress");
await uploadTestImage(page);
// Default mode is Target Size; switch to Quality mode first
await page.getByRole("button", { name: "Quality" }).click();
await expect(page.locator("#compress-quality")).toBeVisible();
await expect(page.getByText("Smallest file")).toBeVisible();
await expect(page.getByText("Best quality")).toBeVisible();
});
test("target size mode shows size input", async ({ loggedInPage: page }) => {
await page.goto("/image/compress");
await uploadTestImage(page);
await page.getByRole("button", { name: "Target Size" }).click();
await expect(page.locator("#compress-target-size")).toBeVisible();
});
test("quality slider is interactive", async ({ loggedInPage: page }) => {
await page.goto("/image/compress");
await uploadTestImage(page);
// Default mode is Target Size; switch to Quality mode first
await page.getByRole("button", { name: "Quality" }).click();
const slider = page.locator("#compress-quality");
await expect(slider).toBeVisible();
await expect(slider).toHaveAttribute("type", "range");
});
test("target size mode shows size input and unit", async ({ loggedInPage: page }) => {
await page.goto("/image/compress");
await uploadTestImage(page);
await page.getByRole("button", { name: "Target Size" }).click();
await expect(page.locator("#compress-target-size")).toBeVisible();
});
test("submit button uses data-testid", async ({ loggedInPage: page }) => {
await page.goto("/image/compress");
await uploadTestImage(page);
await expect(page.getByTestId("compress-submit")).toBeVisible();
});
test("switching between Quality and Target Size modes", async ({ loggedInPage: page }) => {
await page.goto("/image/compress");
await uploadTestImage(page);
// Default mode is Target Size; verify its input is visible
await expect(page.locator("#compress-target-size")).toBeVisible();
// Switch to Quality
await page.getByRole("button", { name: "Quality" }).click();
await expect(page.locator("#compress-quality")).toBeVisible();
// Switch back to Target Size
await page.getByRole("button", { name: "Target Size" }).click();
await expect(page.locator("#compress-target-size")).toBeVisible();
});
test("submit disabled without file, enabled with file in quality mode", async ({
loggedInPage: page,
}) => {
await page.goto("/image/compress");
// Before upload the settings panel (and its submit) is not rendered yet.
await expect(page.getByTestId("compress-submit")).toHaveCount(0);
await uploadTestImage(page);
// Default mode is Target Size (requires a value), switch to Quality mode
await page.getByRole("button", { name: "Quality" }).click();
await expect(page.getByTestId("compress-submit")).toBeEnabled();
});
test("processes compression and shows download with size info", async ({
loggedInPage: page,
}) => {
await page.goto("/image/compress");
await uploadTestImage(page);
// Default mode is Target Size; switch to Quality mode so submit is enabled
await page.getByRole("button", { name: "Quality" }).click();
await page.getByTestId("compress-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("compress-download")).toBeVisible({ timeout: 15_000 });
// Verify size comparison is displayed
await expect(page.getByText("Saved:")).toBeVisible();
});
});
// ========================================================================
// UNDO / STATE RESET (Cross-tool tests)
// ========================================================================
test.describe("Undo and State Reset", () => {
test("resize: undo after processing reverts to upload state", async ({
loggedInPage: page,
}) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await page.locator("#resize-width").fill("50");
await page.getByTestId("resize-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("resize-download")).toBeVisible({ timeout: 15_000 });
// Click the Undo button in the review panel
await page.getByRole("button", { name: /adjust settings/i }).click();
// Should return to settings panel with upload still present (no dropzone)
await expect(page.getByTestId("resize-submit")).toBeVisible({ timeout: 5_000 });
// Download should no longer be visible
await expect(page.getByTestId("resize-download")).not.toBeVisible();
});
test("crop: undo after processing returns to crop canvas", async ({ loggedInPage: page }) => {
await page.goto("/image/crop");
await uploadTestImage(page);
await page.waitForTimeout(1000);
const widthInputs = page.locator("input[type='number']");
if ((await widthInputs.count()) >= 4) {
await widthInputs.nth(2).fill("50");
await widthInputs.nth(3).fill("50");
}
await page.getByTestId("crop-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("crop-download")).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: /adjust settings/i }).click();
await expect(page.getByTestId("crop-submit")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("crop-download")).not.toBeVisible();
});
test("rotate: undo after processing returns to rotate controls", async ({
loggedInPage: page,
}) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
await page.getByTestId("rotate-right").click();
await page.getByTestId("rotate-submit").click();
await waitForProcessing(page);
await expect(page.locator("[data-download-button]").first()).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: /adjust settings/i }).click();
await expect(page.getByTestId("rotate-submit")).toBeVisible({ timeout: 5_000 });
});
test("convert: undo after processing returns to format selector", async ({
loggedInPage: page,
}) => {
await page.goto("/image/convert");
await uploadTestImage(page);
await page.selectOption("#convert-target-format", "webp");
await page.getByTestId("convert-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("convert-download")).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: /adjust settings/i }).click();
await expect(page.getByTestId("convert-submit")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("convert-download")).not.toBeVisible();
});
test("compress: undo after processing returns to quality slider", async ({
loggedInPage: page,
}) => {
await page.goto("/image/compress");
await uploadTestImage(page);
// Default mode is Target Size; switch to Quality mode so submit is enabled
await page.getByRole("button", { name: "Quality" }).click();
await page.getByTestId("compress-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("compress-download")).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: /adjust settings/i }).click();
await expect(page.getByTestId("compress-submit")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("compress-download")).not.toBeVisible();
// Quality slider should still be present
await expect(page.locator("#compress-quality")).toBeVisible();
});
test("resize: clear all returns to dropzone", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await expect(page.locator("#resize-width")).toBeVisible();
// Click Clear all link
await page.getByText("Clear all").click();
// Should return to dropzone
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
});
test("navigate away from tool resets state", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await page.locator("#resize-width").fill("50");
// Navigate to a different tool
await page.goto("/image/crop");
await expect(page.getByText("Crop").first()).toBeVisible();
// Navigate back -- state should be reset
await page.goto("/image/resize");
await expect(page.getByText("Upload from computer")).toBeVisible();
});
});
// ========================================================================
// RESULT DISPLAY MODE VERIFICATION
// ========================================================================
test.describe("Result Display Modes", () => {
test("resize: shows side-by-side display after processing", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await page.locator("#resize-width").fill("50");
await page.getByTestId("resize-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("resize-download")).toBeVisible({ timeout: 15_000 });
// Side-by-side mode shows Original and Processed labels (no colon)
await expect(page.getByText(/Original/i).first()).toBeVisible();
await expect(page.getByText(/Processed/i).first()).toBeVisible();
});
test("compress: shows before-after display with size savings", async ({
loggedInPage: page,
}) => {
await page.goto("/image/compress");
await uploadTestImage(page);
// Default mode is Target Size; switch to Quality mode so submit is enabled
await page.getByRole("button", { name: "Quality" }).click();
await page.getByTestId("compress-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("compress-download")).toBeVisible({ timeout: 15_000 });
// Before-after mode shows savings info
await expect(page.getByText("Saved:")).toBeVisible();
});
});
// ========================================================================
// RESIZE: LINKED ASPECT RATIO AUTO-UPDATE
// ========================================================================
test.describe("Resize Aspect Ratio Linked Fields", () => {
// Selecting a ratio chip on the Custom tab locks the width/height fields:
// editing one recomputes the other. 16:9 is fixture-independent (1600 -> 900).
test("width auto-updates height when a ratio is locked", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await page.getByRole("button", { name: "16:9", exact: true }).click();
await page.locator("#resize-width").fill("1600");
await expect(page.locator("#resize-height")).toHaveValue("900");
});
test("height auto-updates width when a ratio is locked", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await page.getByRole("button", { name: "16:9", exact: true }).click();
await page.locator("#resize-height").fill("900");
await expect(page.locator("#resize-width")).toHaveValue("1600");
});
});
// ========================================================================
// ROTATE: LIVE PREVIEW VERIFICATION
// ========================================================================
test.describe("Rotate Live Preview", () => {
test("rotating 90 degrees applies CSS transform to preview image", async ({
loggedInPage: page,
}) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
const preview = page.getByRole("img", { name: "test-image.png" });
await expect(preview).toBeVisible();
await page.getByTestId("rotate-right").click();
await expect(preview).toHaveAttribute("style", /transform: rotate\(90deg\)/);
});
test("flip horizontal applies CSS transform to preview image", async ({
loggedInPage: page,
}) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
const preview = page.getByRole("img", { name: "test-image.png" });
await expect(preview).toBeVisible();
await page.getByTestId("rotate-flip-h").click();
await expect(preview).toHaveAttribute("style", /transform: scaleX\(-1\)/);
});
test("reset all changes reverts preview transform", async ({ loggedInPage: page }) => {
await page.goto("/image/rotate");
await uploadTestImage(page);
const preview = page.getByRole("img", { name: "test-image.png" });
await expect(preview).toBeVisible();
await page.getByTestId("rotate-right").click();
await expect(preview).toHaveAttribute("style", /transform: rotate\(90deg\)/);
await page.getByRole("button", { name: "Reset all changes" }).click();
await expect(preview).toHaveCSS("transform", "none");
});
});
// ========================================================================
// CROP: INTERACTIVE CANVAS DRAG HANDLES
// ========================================================================
test.describe("Crop Interactive Canvas", () => {
test("crop canvas renders with ReactCrop component after upload", async ({
loggedInPage: page,
}) => {
await page.goto("/image/crop");
await uploadTestImage(page);
await page.waitForTimeout(1000);
// ReactCrop component should render
const cropContainer = page.locator(".ReactCrop");
await expect(cropContainer).toBeVisible();
});
test("crop handles are visible on the canvas", async ({ loggedInPage: page }) => {
await page.goto("/image/crop");
await uploadTestImage(page);
await page.waitForTimeout(1000);
// ReactCrop renders drag handles as elements with specific classes
const cropContainer = page.locator(".ReactCrop");
await expect(cropContainer).toBeVisible();
// The crop selection area should be present
const cropSelection = page.locator(".ReactCrop__crop-selection");
if (await cropSelection.isVisible({ timeout: 2000 }).catch(() => false)) {
await expect(cropSelection).toBeVisible();
}
});
test("dragging on crop canvas updates numeric position inputs", async ({
loggedInPage: page,
}) => {
await page.goto("/image/crop");
await uploadTestImage(page);
await page.waitForTimeout(1000);
const cropImg = page.locator(".ReactCrop img");
await expect(cropImg).toBeVisible();
const box = await cropImg.boundingBox();
expect(box).not.toBeNull();
if (!box) return;
// Get initial crop X/Y values
const _initialX = await page.locator("#crop-x").inputValue();
const _initialY = await page.locator("#crop-y").inputValue();
// Perform a drag on the crop canvas to create/modify crop region
await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.2);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.8, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
// After dragging, the numeric inputs should have updated
const _newX = await page.locator("#crop-x").inputValue();
const _newY = await page.locator("#crop-y").inputValue();
const newWidth = await page.locator("#crop-width").inputValue();
const newHeight = await page.locator("#crop-height").inputValue();
// At least width/height should be non-zero after drag
expect(Number(newWidth)).toBeGreaterThan(0);
expect(Number(newHeight)).toBeGreaterThan(0);
});
test("selecting 1:1 aspect ratio constrains crop box proportions", async ({
loggedInPage: page,
}) => {
await page.goto("/image/crop");
await uploadTestImage(page);
await page.waitForTimeout(1000);
// Select 1:1 aspect ratio
await page.getByRole("button", { name: "1:1" }).click();
await page.waitForTimeout(300);
// Now drag to create a crop region
const cropImg = page.locator(".ReactCrop img");
const box = await cropImg.boundingBox();
if (!box) return;
await page.mouse.move(box.x + 10, box.y + 10);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.7, box.y + box.height * 0.9, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
// Width and height should be equal (1:1 aspect)
const cropWidth = Number(await page.locator("#crop-width").inputValue());
const cropHeight = Number(await page.locator("#crop-height").inputValue());
if (cropWidth > 0 && cropHeight > 0) {
// Allow small rounding tolerance
expect(Math.abs(cropWidth - cropHeight)).toBeLessThanOrEqual(2);
}
});
});
});