mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
237 lines
9.3 KiB
TypeScript
237 lines
9.3 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { expect, getE2eRunRoot, test, uploadTestImage, waitForProcessing } from "./helpers";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Full user session: simulates a real user uploading images, applying
|
|
// different tools in sequence, and downloading results.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("Full user session", () => {
|
|
test("upload -> resize -> download cycle", async ({ loggedInPage: page }) => {
|
|
// Navigate to resize tool
|
|
await page.goto("/image/resize");
|
|
await expect(page.getByText("Resize").first()).toBeVisible();
|
|
|
|
// Upload test image
|
|
await uploadTestImage(page);
|
|
|
|
// Verify the upload was accepted (dropzone is replaced by the viewer)
|
|
await expect(page.getByText("Upload from computer")).not.toBeVisible();
|
|
|
|
// Set width to 200
|
|
await page.locator("input[placeholder='Auto']").first().fill("200");
|
|
|
|
// Set height to 200
|
|
await page.locator("input[placeholder='Auto']").nth(1).fill("200");
|
|
|
|
// Click resize button
|
|
await page.getByRole("button", { name: "Resize" }).click();
|
|
await waitForProcessing(page);
|
|
|
|
// Verify download button appears
|
|
const downloadBtn = page.getByRole("link", { name: /download/i }).first();
|
|
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Click download and verify a file is received
|
|
const downloadPromise = page.waitForEvent("download");
|
|
await downloadBtn.click();
|
|
const download = await downloadPromise;
|
|
|
|
// Verify the download has a filename
|
|
expect(download.suggestedFilename()).toBeTruthy();
|
|
|
|
// Save to disk and verify it is a non-empty file
|
|
const downloadPath = path.join(getE2eRunRoot(), "download-resize-result");
|
|
await download.saveAs(downloadPath);
|
|
const stat = fs.statSync(downloadPath);
|
|
expect(stat.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("upload -> rotate 90 -> download cycle", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/rotate");
|
|
await expect(page.getByText("Rotate").first()).toBeVisible();
|
|
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText("Upload from computer")).not.toBeVisible();
|
|
|
|
// Click the clockwise 90° rotation button and wait for state
|
|
await page.getByTestId("rotate-right").click();
|
|
await expect(page.locator("input[inputmode='numeric']")).toHaveValue("90", { timeout: 2000 });
|
|
|
|
// Click the process button (button text is "Apply")
|
|
await page.getByTestId("rotate-submit").click();
|
|
await waitForProcessing(page);
|
|
|
|
// Verify result. Rotate has no download link of its own; the generic
|
|
// review panel renders the download affordance as [data-download-button].
|
|
const downloadBtn = page.locator("[data-download-button]").first();
|
|
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
|
|
|
|
const downloadPromise = page.waitForEvent("download");
|
|
await downloadBtn.click();
|
|
const download = await downloadPromise;
|
|
expect(download.suggestedFilename()).toBeTruthy();
|
|
});
|
|
|
|
test("upload -> convert to JPEG -> download cycle", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/convert");
|
|
await expect(page.getByText("Convert").first()).toBeVisible();
|
|
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText("Upload from computer")).not.toBeVisible();
|
|
|
|
// The convert tool has a format selector - look for JPEG option
|
|
// Try selecting JPEG from the format options
|
|
const jpegOption = page.getByRole("button", { name: /jpeg|jpg/i }).first();
|
|
if (await jpegOption.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await jpegOption.click();
|
|
}
|
|
// Otherwise the default format selection is fine
|
|
|
|
// Click convert button
|
|
await page.getByRole("button", { name: /convert/i }).click();
|
|
await waitForProcessing(page);
|
|
|
|
// Verify download
|
|
const downloadBtn = page.getByRole("link", { name: /download/i }).first();
|
|
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
|
|
|
|
const downloadPromise = page.waitForEvent("download");
|
|
await downloadBtn.click();
|
|
const download = await downloadPromise;
|
|
expect(download.suggestedFilename()).toBeTruthy();
|
|
});
|
|
|
|
test("upload -> crop -> download cycle", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/crop");
|
|
await expect(page.getByText("Crop").first()).toBeVisible();
|
|
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText("Upload from computer")).not.toBeVisible();
|
|
|
|
// Set crop dimensions via number inputs
|
|
// Crop component has: left, top, width, height inputs
|
|
const numberInputs = page.locator("input[type='number']");
|
|
const count = await numberInputs.count();
|
|
if (count >= 4) {
|
|
// Ensure width and height are set (indices 2 and 3)
|
|
await numberInputs.nth(2).fill("50");
|
|
await numberInputs.nth(3).fill("50");
|
|
} else if (count >= 2) {
|
|
// If fewer inputs, fill the first two
|
|
await numberInputs.nth(0).fill("50");
|
|
await numberInputs.nth(1).fill("50");
|
|
}
|
|
|
|
// Click crop button
|
|
await page.locator("button[type='submit']").click();
|
|
await waitForProcessing(page);
|
|
|
|
// Verify download
|
|
const downloadBtn = page.getByRole("link", { name: /download/i }).first();
|
|
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
|
|
|
|
const downloadPromise = page.waitForEvent("download");
|
|
await downloadBtn.click();
|
|
const download = await downloadPromise;
|
|
expect(download.suggestedFilename()).toBeTruthy();
|
|
});
|
|
|
|
test("upload -> compress -> download cycle", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/compress");
|
|
await expect(page.getByText("Compress").first()).toBeVisible();
|
|
|
|
await uploadTestImage(page);
|
|
await expect(page.getByText("Upload from computer")).not.toBeVisible();
|
|
|
|
// Compress defaults to Target Size mode with an empty (invalid) value,
|
|
// which keeps the submit disabled. Switch to Quality mode first.
|
|
await page.getByRole("button", { name: "Quality" }).click();
|
|
await page.getByTestId("compress-submit").click();
|
|
await waitForProcessing(page);
|
|
|
|
const downloadBtn = page.getByTestId("compress-download");
|
|
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
|
|
|
|
const downloadPromise = page.waitForEvent("download");
|
|
await downloadBtn.click();
|
|
const download = await downloadPromise;
|
|
expect(download.suggestedFilename()).toBeTruthy();
|
|
|
|
// Save and verify the downloaded file is a valid non-empty image
|
|
const downloadPath = path.join(getE2eRunRoot(), "download-compress-result");
|
|
await download.saveAs(downloadPath);
|
|
const stat = fs.statSync(downloadPath);
|
|
expect(stat.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("multi-tool session: resize then compress", async ({ loggedInPage: page }) => {
|
|
// Step 1: Resize
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
await page.locator("input[placeholder='Auto']").first().fill("200");
|
|
await page.getByTestId("resize-submit").click();
|
|
await waitForProcessing(page);
|
|
|
|
const resizeDownloadBtn = page.getByTestId("resize-download");
|
|
await expect(resizeDownloadBtn).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Step 2: Navigate to compress and process a new image
|
|
await page.goto("/image/compress");
|
|
await uploadTestImage(page);
|
|
// Switch to Quality mode so the disabled Target Size default is bypassed.
|
|
await page.getByRole("button", { name: "Quality" }).click();
|
|
await page.getByTestId("compress-submit").click();
|
|
await waitForProcessing(page);
|
|
|
|
const compressDownloadBtn = page.getByTestId("compress-download");
|
|
await expect(compressDownloadBtn).toBeVisible({ timeout: 15_000 });
|
|
});
|
|
|
|
test("upload file then navigate away and back retains tool state", async ({
|
|
loggedInPage: page,
|
|
}) => {
|
|
// Go to resize, upload, configure
|
|
await page.goto("/image/resize");
|
|
await uploadTestImage(page);
|
|
await page.locator("input[placeholder='Auto']").first().fill("300");
|
|
|
|
// Navigate away to the home catalog. In 2.0 the home page is a tool
|
|
// catalog (search + tabs + link cards), not a dropzone.
|
|
await page.goto("/");
|
|
await expect(page.locator("[data-search-input]")).toBeVisible();
|
|
|
|
// Navigate back to resize - the tool should reset (fresh state)
|
|
await page.goto("/image/resize");
|
|
await expect(page.getByText("Upload from computer")).toBeVisible();
|
|
});
|
|
|
|
test("download button triggers actual file download", async ({ loggedInPage: page }) => {
|
|
await page.goto("/image/strip-metadata");
|
|
await uploadTestImage(page);
|
|
|
|
await page.getByRole("button", { name: /remove metadata/i }).click();
|
|
await waitForProcessing(page);
|
|
|
|
const downloadBtn = page.getByRole("link", { name: /download/i }).first();
|
|
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Intercept the download event
|
|
const downloadPromise = page.waitForEvent("download");
|
|
await downloadBtn.click();
|
|
const download = await downloadPromise;
|
|
|
|
// Verify download properties
|
|
const filename = download.suggestedFilename();
|
|
expect(filename).toBeTruthy();
|
|
expect(filename.length).toBeGreaterThan(0);
|
|
|
|
// Save and confirm it wrote bytes
|
|
const savePath = path.join(getE2eRunRoot(), "download-strip-metadata-result");
|
|
await download.saveAs(savePath);
|
|
const stat = fs.statSync(savePath);
|
|
expect(stat.size).toBeGreaterThan(0);
|
|
});
|
|
});
|