Files
SnapOtter/tests/e2e-editor/editor-full-gui-test.spec.ts
T
SnapOtter 3120e6708d fix(editor): apply layer effects + object flip, add Beta badge, repair e2e specs
While getting the editor e2e suite green, three "stale test" failures turned
out to be real bugs (per the reporter's hunch that tests might be catching
real issues):

- Layer effects (drop shadow, glows) never applied. The panel wrote effects
  into `attrs.effects` through updateObject, but the panel and renderer both
  read the object's top-level `effects`, so the toggle never persisted. Add a
  dedicated `setObjectEffects` store action and route the panel through it.
- Object flip (transform tool) did nothing. No object renderer applied
  `scaleX`/`scaleY`, and the flip negated scale without compensating position.
  Apply scale in the renderers and flip in place: mirror points for stroke
  objects, negate scale + shift position for sized objects.

(The paint-bucket / pixel-tool coordinate bug and the broken-at-non-100%-zoom
export were fixed in the preceding #259 change.)

Also adds a small "Beta" badge to the editor (welcome heading + nav link) and
repairs ~18 stale editor e2e specs whose selectors/assertions had drifted from
the current UI: the options bar is `h-9` not `h-10` (added a stable
`data-testid`), the menu bar is `h-8`/`bg-background`, the flip button
aria-labels are lowercase, the welcome "Image Editor" heading collides with an
sr-only `<h1>`, the color-picker tabs need a role-scoped selector, and the
magic-wand / flip tests now use deterministic setup and assert the actual
effect instead of fragile screenshot diffs.
2026-06-17 14:21:35 +08:00

712 lines
23 KiB
TypeScript

import path from "node:path";
import { expect, type Page, test } from "@playwright/test";
const SCREENSHOT_DIR = path.join(__dirname, "screenshots");
let screenshotIndex = 0;
async function snap(page: Page, name: string) {
screenshotIndex++;
const filename = `${String(screenshotIndex).padStart(2, "0")}-${name}.png`;
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 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 }) => {
screenshotIndex = 0;
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");
});
});