Files
SnapOtter/tests/e2e-editor/editor-full-gui-test.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

712 lines
23 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test";
let screenshotIndex = 0;
let screenshotOutputPath: (filename: string) => string;
async function snap(page: Page, name: string) {
screenshotIndex++;
const filename = `${String(screenshotIndex).padStart(2, "0")}-${name}.png`;
await page.screenshot({ path: screenshotOutputPath(filename) });
}
async function login(page: Page) {
await page.goto("/login");
await page.waitForTimeout(2000);
await page.fill('input[placeholder*="username" i]', "admin");
await page.fill('input[placeholder*="password" i]', "admin");
await page.click('button:has-text("Login")');
await page.waitForURL("**/", { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(2000);
}
async function createNewDocument(page: Page) {
const newDocBtn = page.getByText("New Document");
if (await newDocBtn.isVisible().catch(() => false)) {
await newDocBtn.click();
await page.waitForTimeout(500);
await page.locator('button:has-text("Create")').click();
await page.waitForTimeout(2000);
}
}
async function getCanvasBox(page: Page) {
const canvas = page.locator("canvas").first();
const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found");
return box;
}
test.describe("Image Editor - Full GUI Test Suite", () => {
test.beforeEach(async ({ page }, testInfo) => {
screenshotIndex = 0;
screenshotOutputPath = (filename) => testInfo.outputPath(filename);
await login(page);
});
test("01 - Navigation & Layout", async ({ page }) => {
// Check sidebar has Editor item
await page.goto("/");
await page.waitForTimeout(2000);
const editorLink = page.locator('a:has-text("Editor")');
await expect(editorLink).toBeVisible();
await snap(page, "sidebar-with-editor");
// Navigate to editor
await editorLink.click();
await page.waitForTimeout(2000);
await expect(page).toHaveURL(/\/editor/);
await snap(page, "editor-welcome-screen");
// Verify four-zone layout elements
const toolbar = page.locator('[data-tool="move"]');
await expect(toolbar).toBeVisible();
const layersTab = page.locator('[data-testid="tab-layers"]');
await expect(layersTab).toBeVisible();
const adjustmentsTab = page.locator('[data-testid="tab-adjustments"]');
await expect(adjustmentsTab).toBeVisible();
const historyTab = page.locator('[data-testid="tab-history"]');
await expect(historyTab).toBeVisible();
});
test("02 - Welcome Screen & New Document", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
// Welcome screen visible (the visible <h2>; an sr-only <h1> shares the text)
await expect(page.getByRole("heading", { level: 2, name: "Image Editor" })).toBeVisible();
await expect(page.getByText("Open Image")).toBeVisible();
await expect(page.getByText("New Document")).toBeVisible();
await snap(page, "welcome-screen");
// Open new document dialog
await page.getByText("New Document").click();
await page.waitForTimeout(500);
await expect(page.getByText("Preset")).toBeVisible();
await snap(page, "new-doc-dialog");
// Change preset to Instagram
await page.locator("select").first().selectOption("1080x1080 (Instagram)");
await page.waitForTimeout(300);
await snap(page, "new-doc-instagram-preset");
// Select transparent background
await page.getByText("Transparent").click();
await page.waitForTimeout(300);
await snap(page, "new-doc-transparent");
// Create the document
await page.locator('button:has-text("Create")').click();
await page.waitForTimeout(2000);
// Canvas should be visible with checkerboard (transparent)
const canvas = page.locator("canvas").first();
await expect(canvas).toBeVisible();
await snap(page, "canvas-after-create");
});
test("03 - Brush Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Activate brush via toolbar
await page.locator('[data-tool="brush"]').click();
await page.waitForTimeout(300);
await expect(page.locator('[data-tool-active="true"]')).toHaveAttribute("data-tool", "brush");
await snap(page, "brush-tool-active");
// Verify options bar shows size and opacity
await expect(page.getByText("Size", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Opacity", { exact: true }).first()).toBeVisible();
// Draw a brush stroke
const box = await getCanvasBox(page);
const before = await page.locator("canvas").first().screenshot();
await page.mouse.move(box.x + 100, box.y + 200);
await page.mouse.down();
await page.mouse.move(box.x + 500, box.y + 300, { steps: 20 });
await page.mouse.up();
await page.waitForTimeout(500);
const after = await page.locator("canvas").first().screenshot();
expect(Buffer.compare(before, after)).not.toBe(0);
await snap(page, "brush-stroke-drawn");
// Change brush size via [ and ] keys
await page.keyboard.press("]");
await page.keyboard.press("]");
await page.keyboard.press("]");
await page.waitForTimeout(300);
await snap(page, "brush-size-increased");
});
test("04 - Eraser Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Draw something first with brush
await page.locator('[data-tool="brush"]').click();
await page.waitForTimeout(300);
const box = await getCanvasBox(page);
await page.mouse.move(box.x + 200, box.y + 200);
await page.mouse.down();
await page.mouse.move(box.x + 500, box.y + 200, { steps: 15 });
await page.mouse.up();
await page.waitForTimeout(500);
// Activate eraser via E key
await page.keyboard.press("e");
await page.waitForTimeout(300);
await expect(page.locator('[data-tool-active="true"]')).toHaveAttribute("data-tool", "eraser");
await snap(page, "eraser-tool-active");
// Erase over the stroke
await page.mouse.move(box.x + 300, box.y + 190);
await page.mouse.down();
await page.mouse.move(box.x + 400, box.y + 210, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
await snap(page, "eraser-stroke");
});
test("05 - Shape Tools", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Rectangle
await page.locator('[data-tool="shape-rect"]').click();
await page.waitForTimeout(300);
await snap(page, "shape-rect-options");
await page.mouse.move(box.x + 100, box.y + 100);
await page.mouse.down();
await page.mouse.move(box.x + 300, box.y + 250, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
await snap(page, "shape-rect-drawn");
// Switch to ellipse via shape dropdown if available
const shapeDropdown = page.locator("select").first();
if (await shapeDropdown.isVisible().catch(() => false)) {
await shapeDropdown.selectOption("Ellipse");
await page.waitForTimeout(300);
}
// Draw another shape
await page.mouse.move(box.x + 400, box.y + 100);
await page.mouse.down();
await page.mouse.move(box.x + 600, box.y + 250, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
await snap(page, "shape-ellipse-drawn");
// Select and move with move tool
await page.locator('[data-tool="move"]').click();
await page.waitForTimeout(300);
await page.mouse.click(box.x + 200, box.y + 175);
await page.waitForTimeout(500);
await snap(page, "shape-selected-with-handles");
});
test("06 - Text Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Activate text tool via T key
await page.keyboard.press("t");
await page.waitForTimeout(300);
await expect(page.locator('[data-tool-active="true"]')).toHaveAttribute("data-tool", "text");
await snap(page, "text-tool-active");
// Click on canvas to place text
await page.mouse.click(box.x + 200, box.y + 200);
await page.waitForTimeout(1000);
// Type some text (textarea should be active)
await page.keyboard.type("Hello SnapOtter!");
await page.waitForTimeout(500);
await snap(page, "text-typing");
// Click away to finalize
await page.mouse.click(box.x + 50, box.y + 50);
await page.waitForTimeout(500);
await snap(page, "text-finalized");
});
test("07 - Crop Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const _box = await getCanvasBox(page);
// Activate crop via C key
await page.keyboard.press("c");
await page.waitForTimeout(500);
await expect(page.locator('[data-tool-active="true"]')).toHaveAttribute("data-tool", "crop");
await snap(page, "crop-tool-active");
// Verify crop options show aspect ratio
const _cropOptions = page.getByText(/Free|1:1|4:3|16:9/);
await snap(page, "crop-options-bar");
});
test("08 - Layer System", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Layers tab should be active by default
await expect(page.locator('[data-testid="tab-layers"]')).toBeVisible();
await page.locator('[data-testid="tab-layers"]').click();
await page.waitForTimeout(300);
// Should see Layer 1
await expect(page.getByText("Layer 1")).toBeVisible();
await snap(page, "layers-panel-default");
// Add a new layer via + button
const _addLayerBtn = page
.locator("button")
.filter({ has: page.locator("svg") })
.locator("xpath=//button[contains(@class, 'items-center')]")
.first();
// Try clicking the + icon at bottom of layers panel
const _plusButtons = page
.locator('[data-testid="tab-layers"]')
.locator("..")
.locator("..")
.locator("button");
await snap(page, "layers-before-add");
// Use keyboard shortcut to add layer
await page.keyboard.press("Control+Shift+n");
await page.waitForTimeout(500);
await snap(page, "layers-after-add");
// Check blend mode dropdown
const blendDropdown = page.locator("select").filter({ hasText: /Normal/ });
if (await blendDropdown.isVisible().catch(() => false)) {
await blendDropdown.click();
await page.waitForTimeout(300);
await snap(page, "blend-mode-dropdown");
}
// Toggle layer visibility
const eyeIcons = page.locator(
'[aria-label*="visibility" i], [aria-label*="toggle" i], [title*="visibility" i]',
);
if (
await eyeIcons
.first()
.isVisible()
.catch(() => false)
) {
await eyeIcons.first().click();
await page.waitForTimeout(300);
await snap(page, "layer-visibility-toggled");
}
});
test("09 - Color System", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Foreground/background swatches should be visible
await expect(page.getByText("Foreground")).toBeVisible();
await snap(page, "color-panel-default");
// Click foreground swatch to open picker
const _fgSwatch = page.locator('[data-testid="color-foreground"]').or(
page
.locator("button")
.filter({ hasText: /Foreground/ })
.locator("..")
.locator("button")
.first(),
);
// Try clicking the color swatch area
const colorArea = page.getByText("Foreground").locator("..").locator("button").first();
if (await colorArea.isVisible().catch(() => false)) {
await colorArea.click();
await page.waitForTimeout(500);
await snap(page, "color-picker-open");
}
// Test D key resets to black/white
await page.keyboard.press("d");
await page.waitForTimeout(300);
await snap(page, "colors-reset-to-default");
// Test X key swaps colors
await page.keyboard.press("x");
await page.waitForTimeout(300);
await snap(page, "colors-swapped");
});
test("10 - Adjustments Panel", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Switch to Adjustments tab
await page.locator('[data-testid="tab-adjustments"]').click();
await page.waitForTimeout(500);
await snap(page, "adjustments-panel");
// Verify adjustment sliders present
await expect(page.getByText("Brightness", { exact: true })).toBeVisible();
await expect(page.getByText("Contrast", { exact: true })).toBeVisible();
await expect(page.getByText("Saturation", { exact: true })).toBeVisible();
await snap(page, "adjustments-sliders");
// Verify auto buttons
await expect(page.getByText("Auto Tone")).toBeVisible();
await expect(page.getByText("Auto Contrast")).toBeVisible();
await snap(page, "auto-buttons");
// Scroll to see levels section
const panel = page
.locator('[data-testid="tab-adjustments"]')
.locator("..")
.locator("..")
.locator("div.overflow-y-auto");
if (await panel.isVisible().catch(() => false)) {
await panel.evaluate((el) => (el.scrollTop = 400));
await page.waitForTimeout(500);
await snap(page, "levels-section");
await panel.evaluate((el) => (el.scrollTop = 800));
await page.waitForTimeout(500);
await snap(page, "curves-section");
await panel.evaluate((el) => (el.scrollTop = 1200));
await page.waitForTimeout(500);
await snap(page, "filters-section");
}
});
test("11 - History & Undo/Redo", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Draw a shape
await page.locator('[data-tool="shape-rect"]').click();
await page.waitForTimeout(300);
await page.mouse.move(box.x + 100, box.y + 100);
await page.mouse.down();
await page.mouse.move(box.x + 300, box.y + 250, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
// Draw a brush stroke
await page.keyboard.press("b");
await page.waitForTimeout(300);
await page.mouse.move(box.x + 400, box.y + 150);
await page.mouse.down();
await page.mouse.move(box.x + 600, box.y + 300, { steps: 15 });
await page.mouse.up();
await page.waitForTimeout(500);
// Open history panel
await page.locator('[data-testid="tab-history"]').click();
await page.waitForTimeout(500);
await snap(page, "history-with-actions");
// Verify history entries exist
const _historyEntries = page
.locator('[data-testid="tab-history"]')
.locator("..")
.locator("..")
.locator("div.overflow-y-auto");
await snap(page, "history-panel-entries");
// Undo with Ctrl+Z
const beforeUndo = await page.locator("canvas").first().screenshot();
await page.keyboard.press("Control+z");
await page.waitForTimeout(500);
const afterUndo = await page.locator("canvas").first().screenshot();
expect(Buffer.compare(beforeUndo, afterUndo)).not.toBe(0);
await snap(page, "after-undo");
// Redo with Ctrl+Shift+Z
await page.keyboard.press("Control+Shift+z");
await page.waitForTimeout(500);
await snap(page, "after-redo");
});
test("12 - Keyboard Shortcuts", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const toolShortcuts: [string, string][] = [
["v", "move"],
["b", "brush"],
["e", "eraser"],
["u", "shape-rect"],
["t", "text"],
["c", "crop"],
["i", "eyedropper"],
["h", "hand"],
["z", "zoom"],
["m", "marquee-rect"],
["g", "fill"],
];
for (const [key, _expectedTool] of toolShortcuts) {
await page.keyboard.press(key);
await page.waitForTimeout(200);
const activeTool = page.locator('[data-tool-active="true"]');
const toolAttr = await activeTool.getAttribute("data-tool");
// Just verify the active tool changed (some tools may have different sub-types)
expect(toolAttr).toBeTruthy();
}
await snap(page, "keyboard-shortcuts-tested");
// Test D resets colors
await page.keyboard.press("d");
await page.waitForTimeout(200);
// Test X swaps
await page.keyboard.press("x");
await page.waitForTimeout(200);
await snap(page, "color-shortcuts-tested");
});
test("13 - Right Panel Toggle", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Panel should be visible
await expect(page.locator('[data-testid="tab-layers"]')).toBeVisible();
await snap(page, "panel-visible");
// Press Tab to toggle panel
await page.keyboard.press("Tab");
await page.waitForTimeout(500);
await snap(page, "panel-collapsed");
// Press Tab again to restore
await page.keyboard.press("Tab");
await page.waitForTimeout(500);
await snap(page, "panel-restored");
});
test("14 - Multiple Shapes & Move", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Draw 3 shapes
await page.locator('[data-tool="shape-rect"]').click();
await page.waitForTimeout(300);
// Shape 1
await page.mouse.move(box.x + 50, box.y + 50);
await page.mouse.down();
await page.mouse.move(box.x + 200, box.y + 150, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300);
// Shape 2
await page.mouse.move(box.x + 250, box.y + 50);
await page.mouse.down();
await page.mouse.move(box.x + 400, box.y + 150, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300);
// Shape 3
await page.mouse.move(box.x + 450, box.y + 50);
await page.mouse.down();
await page.mouse.move(box.x + 600, box.y + 150, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300);
await snap(page, "three-shapes-drawn");
// Switch to move tool and select shape
await page.keyboard.press("v");
await page.waitForTimeout(300);
await page.mouse.click(box.x + 125, box.y + 100);
await page.waitForTimeout(500);
await snap(page, "shape-selected");
// Drag it
await page.mouse.move(box.x + 125, box.y + 100);
await page.mouse.down();
await page.mouse.move(box.x + 125, box.y + 300, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
await snap(page, "shape-moved");
// Delete with Delete key
await page.mouse.click(box.x + 325, box.y + 100);
await page.waitForTimeout(300);
await page.keyboard.press("Delete");
await page.waitForTimeout(500);
await snap(page, "shape-deleted");
});
test("15 - Export Dialog", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Draw something
await page.locator('[data-tool="shape-rect"]').click();
await page.waitForTimeout(300);
await page.mouse.move(box.x + 200, box.y + 150);
await page.mouse.down();
await page.mouse.move(box.x + 500, box.y + 350, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
// Open export dialog via Ctrl+Shift+S
await page.keyboard.press("Control+Shift+s");
await page.waitForTimeout(1000);
await snap(page, "export-dialog");
// Check format options
const pngBtn = page.getByText("PNG");
const jpegBtn = page.getByText("JPEG");
const webpBtn = page.getByText("WebP");
if (await pngBtn.isVisible().catch(() => false)) {
await expect(pngBtn).toBeVisible();
await expect(jpegBtn).toBeVisible();
await expect(webpBtn).toBeVisible();
// Select JPEG to see quality slider
await jpegBtn.click();
await page.waitForTimeout(300);
await snap(page, "export-jpeg-quality");
}
});
test("16 - Clone Stamp Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Activate clone stamp via S key
await page.keyboard.press("s");
await page.waitForTimeout(300);
await expect(page.locator('[data-tool-active="true"]')).toHaveAttribute(
"data-tool",
"clone-stamp",
);
await snap(page, "clone-stamp-active");
// Verify options bar
await expect(page.getByText("Size")).toBeVisible();
});
test("17 - Dodge/Burn/Sponge Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Activate dodge via O key
await page.keyboard.press("o");
await page.waitForTimeout(300);
await snap(page, "dodge-tool-active");
// Check options show range and exposure
const _optionsBar = page
.locator("div")
.filter({ hasText: /Range|Exposure|Dodge|Burn|Sponge/ })
.first();
await snap(page, "dodge-burn-options");
});
test("18 - Selection Tools", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Marquee selection via M key
await page.keyboard.press("m");
await page.waitForTimeout(300);
await snap(page, "marquee-tool-active");
// Draw a selection
await page.mouse.move(box.x + 100, box.y + 100);
await page.mouse.down();
await page.mouse.move(box.x + 400, box.y + 300, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
await snap(page, "marquee-selection-drawn");
// Lasso via L key
await page.keyboard.press("l");
await page.waitForTimeout(300);
await snap(page, "lasso-tool-active");
// Magic wand via W key
await page.keyboard.press("w");
await page.waitForTimeout(300);
await snap(page, "magic-wand-active");
});
test("19 - Gradient & Fill Tools", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Fill tool via G key
await page.keyboard.press("g");
await page.waitForTimeout(300);
await snap(page, "fill-tool-active");
// Check tolerance option
await expect(page.getByText("Tolerance")).toBeVisible();
await snap(page, "fill-options");
});
test("20 - Navigator Panel", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Navigator should show minimap
await page.waitForTimeout(1000);
await snap(page, "navigator-minimap");
});
test("21 - Status Bar", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Verify zoom percentage
const zoomInput = page.locator('[data-testid="status-zoom"] input');
if (await zoomInput.isVisible().catch(() => false)) {
const zoomValue = await zoomInput.inputValue();
expect(Number(zoomValue)).toBeGreaterThan(0);
}
// Verify dimensions
const dimensions = page.locator('[data-testid="status-dimensions"]');
if (await dimensions.isVisible().catch(() => false)) {
const text = await dimensions.textContent();
expect(text).toContain("1920");
expect(text).toContain("1080");
}
await snap(page, "status-bar");
});
});