From e11e3660d9cc3beab529baa151cebcb127aea753 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Thu, 7 May 2026 09:29:01 +0800 Subject: [PATCH] test: add editor E2E test specs --- playwright.editor.config.ts | 23 +++ tests/e2e-editor/editor-colors.spec.ts | 96 ++++++++++++ tests/e2e-editor/editor-export.spec.ts | 49 ++++++ tests/e2e-editor/editor-history.spec.ts | 75 +++++++++ .../editor-keyboard-shortcuts.spec.ts | 95 ++++++++++++ tests/e2e-editor/editor-layers.spec.ts | 143 ++++++++++++++++++ tests/e2e-editor/editor-navigation.spec.ts | 117 ++++++++++++++ tests/e2e-editor/editor-tools-drawing.spec.ts | 131 ++++++++++++++++ .../e2e-editor/editor-tools-selection.spec.ts | 90 +++++++++++ tests/e2e-editor/helpers.ts | 53 +++++++ 10 files changed, 872 insertions(+) create mode 100644 playwright.editor.config.ts create mode 100644 tests/e2e-editor/editor-colors.spec.ts create mode 100644 tests/e2e-editor/editor-export.spec.ts create mode 100644 tests/e2e-editor/editor-history.spec.ts create mode 100644 tests/e2e-editor/editor-keyboard-shortcuts.spec.ts create mode 100644 tests/e2e-editor/editor-layers.spec.ts create mode 100644 tests/e2e-editor/editor-navigation.spec.ts create mode 100644 tests/e2e-editor/editor-tools-drawing.spec.ts create mode 100644 tests/e2e-editor/editor-tools-selection.spec.ts create mode 100644 tests/e2e-editor/helpers.ts diff --git a/playwright.editor.config.ts b/playwright.editor.config.ts new file mode 100644 index 00000000..ac2c5122 --- /dev/null +++ b/playwright.editor.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e-editor", + timeout: 60_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + retries: 0, + reporter: "html", + use: { + baseURL: "http://localhost:1351", + trace: "retain-on-failure", + screenshot: "only-on-failure", + viewport: { width: 1440, height: 900 }, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/tests/e2e-editor/editor-colors.spec.ts b/tests/e2e-editor/editor-colors.spec.ts new file mode 100644 index 00000000..5a868800 --- /dev/null +++ b/tests/e2e-editor/editor-colors.spec.ts @@ -0,0 +1,96 @@ +import { createNewDocument, expect, test } from "./helpers"; + +test.describe("Editor Colors", () => { + test.beforeEach(async ({ editorPage: page }) => { + await createNewDocument(page); + }); + + test("foreground and background swatches are visible", async ({ editorPage: page }) => { + const fgSwatch = page.locator("[data-testid='fg-color-swatch']"); + const bgSwatch = page.locator("[data-testid='bg-color-swatch']"); + + await expect(fgSwatch).toBeVisible(); + await expect(bgSwatch).toBeVisible(); + }); + + test("click foreground swatch opens color picker", async ({ editorPage: page }) => { + const fgSwatch = page.locator("[data-testid='fg-color-swatch']"); + await fgSwatch.click(); + await page.waitForTimeout(300); + + const picker = page.locator("[data-testid='color-picker-popover']"); + await expect(picker).toBeVisible(); + + // Picker should contain the react-colorful component + await expect(picker.locator(".react-colorful")).toBeVisible(); + }); + + test("click background swatch opens color picker", async ({ editorPage: page }) => { + const bgSwatch = page.locator("[data-testid='bg-color-swatch']"); + await bgSwatch.click(); + await page.waitForTimeout(300); + + const picker = page.locator("[data-testid='color-picker-popover']"); + await expect(picker).toBeVisible(); + }); + + test("D key resets colors to black and white", async ({ editorPage: page }) => { + // First change the foreground color via hex input + const hexInput = page.locator("[data-testid='foreground-hex-input']"); + await hexInput.fill("#FF0000"); + await page.waitForTimeout(300); + + // Press D to reset + // Click canvas area first to ensure no input is focused + await page + .locator("canvas") + .first() + .click({ position: { x: 5, y: 5 } }); + await page.waitForTimeout(200); + await page.keyboard.press("d"); + await page.waitForTimeout(300); + + // Foreground should be black (#000000) + const fgSwatch = page.locator("[data-testid='fg-color-swatch']"); + const fgColor = await fgSwatch.evaluate((el) => (el as HTMLElement).style.backgroundColor); + // rgb(0, 0, 0) is black + expect(fgColor).toContain("rgb(0, 0, 0)"); + }); + + test("swap button exchanges foreground and background", async ({ editorPage: page }) => { + const swapBtn = page.locator("[data-testid='swap-colors']"); + await expect(swapBtn).toBeVisible(); + + // Get initial colors + const fgBefore = await page + .locator("[data-testid='fg-color-swatch']") + .evaluate((el) => (el as HTMLElement).style.backgroundColor); + const bgBefore = await page + .locator("[data-testid='bg-color-swatch']") + .evaluate((el) => (el as HTMLElement).style.backgroundColor); + + await swapBtn.click(); + await page.waitForTimeout(300); + + const fgAfter = await page + .locator("[data-testid='fg-color-swatch']") + .evaluate((el) => (el as HTMLElement).style.backgroundColor); + const bgAfter = await page + .locator("[data-testid='bg-color-swatch']") + .evaluate((el) => (el as HTMLElement).style.backgroundColor); + + expect(fgAfter).toBe(bgBefore); + expect(bgAfter).toBe(fgBefore); + }); + + test("color picker has hex, rgb, and hsl mode tabs", async ({ editorPage: page }) => { + // Open the color picker + await page.locator("[data-testid='fg-color-swatch']").click(); + await page.waitForTimeout(300); + + const picker = page.locator("[data-testid='color-picker-popover']"); + await expect(picker.getByText("HEX", { exact: true })).toBeVisible(); + await expect(picker.getByText("RGB", { exact: true })).toBeVisible(); + await expect(picker.getByText("HSL", { exact: true })).toBeVisible(); + }); +}); diff --git a/tests/e2e-editor/editor-export.spec.ts b/tests/e2e-editor/editor-export.spec.ts new file mode 100644 index 00000000..e3c7609a --- /dev/null +++ b/tests/e2e-editor/editor-export.spec.ts @@ -0,0 +1,49 @@ +import { createNewDocument, expect, test } from "./helpers"; + +test.describe("Editor Export", () => { + test.beforeEach(async ({ editorPage: page }) => { + await createNewDocument(page); + }); + + test("export dialog opens via Ctrl+Shift+S", async ({ editorPage: page }) => { + await page.keyboard.press("Control+Shift+s"); + await page.waitForTimeout(500); + + // Export dialog should appear with "Export Image" heading + await expect(page.getByText("Export Image")).toBeVisible(); + }); + + test("export dialog has format options PNG, JPEG, WebP", async ({ editorPage: page }) => { + await page.keyboard.press("Control+Shift+s"); + await page.waitForTimeout(500); + + await expect(page.getByText("PNG", { exact: true })).toBeVisible(); + await expect(page.getByText("JPEG", { exact: true })).toBeVisible(); + await expect(page.getByText("WebP", { exact: true })).toBeVisible(); + }); + + test("export dialog has dimension inputs and aspect lock", async ({ editorPage: page }) => { + await page.keyboard.press("Control+Shift+s"); + await page.waitForTimeout(500); + + await expect(page.getByText("Dimensions")).toBeVisible(); + await expect(page.getByText("Width")).toBeVisible(); + await expect(page.getByText("Height")).toBeVisible(); + + // Aspect lock button + const lockBtn = page.locator( + "button[aria-label='Unlock aspect ratio'], button[aria-label='Lock aspect ratio']", + ); + await expect(lockBtn).toBeVisible(); + }); + + test("export dialog has export, copy, save, and load buttons", async ({ editorPage: page }) => { + await page.keyboard.press("Control+Shift+s"); + await page.waitForTimeout(500); + + await expect(page.getByText("Export", { exact: true })).toBeVisible(); + await expect(page.getByText("Copy", { exact: false }).first()).toBeVisible(); + await expect(page.getByText("Save Project")).toBeVisible(); + await expect(page.getByText("Load Project")).toBeVisible(); + }); +}); diff --git a/tests/e2e-editor/editor-history.spec.ts b/tests/e2e-editor/editor-history.spec.ts new file mode 100644 index 00000000..4ea1304d --- /dev/null +++ b/tests/e2e-editor/editor-history.spec.ts @@ -0,0 +1,75 @@ +import { createNewDocument, drawOnCanvas, expect, selectTool, test } from "./helpers"; + +test.describe("Editor History Panel", () => { + test.beforeEach(async ({ editorPage: page }) => { + await createNewDocument(page); + }); + + test("history tab shows action list", async ({ editorPage: page }) => { + // Switch to history tab + await page.locator("[data-testid='tab-history']").click(); + await page.waitForTimeout(300); + + // There should be at least one history entry (the initial load) + const entries = page.locator(".flex-1.overflow-y-auto button"); + const count = await entries.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("history panel shows undo and redo buttons", async ({ editorPage: page }) => { + await page.locator("[data-testid='tab-history']").click(); + await page.waitForTimeout(300); + + const undoBtn = page.locator("button[aria-label='Undo']"); + const redoBtn = page.locator("button[aria-label='Redo']"); + + await expect(undoBtn).toBeVisible(); + await expect(redoBtn).toBeVisible(); + }); + + test("drawing creates a history entry", async ({ editorPage: page }) => { + // Switch to history tab first to count entries + await page.locator("[data-testid='tab-history']").click(); + await page.waitForTimeout(300); + + const entriesLocator = page.locator(".flex-1.overflow-y-auto button"); + const countBefore = await entriesLocator.count(); + + // Draw something with the brush + await selectTool(page, "brush"); + await drawOnCanvas(page, 100, 100, 300, 200); + await page.waitForTimeout(500); + + // Switch back to history tab to see new entry + await page.locator("[data-testid='tab-history']").click(); + await page.waitForTimeout(300); + + const countAfter = await entriesLocator.count(); + expect(countAfter).toBeGreaterThan(countBefore); + }); + + test("history shows step count", async ({ editorPage: page }) => { + await page.locator("[data-testid='tab-history']").click(); + await page.waitForTimeout(300); + + // The history panel shows "N / 50" counter + await expect(page.getByText(/\d+\s*\/\s*50/)).toBeVisible(); + }); + + test("undo button is disabled with no history", async ({ editorPage: page }) => { + await page.locator("[data-testid='tab-history']").click(); + await page.waitForTimeout(300); + + // On a fresh document with no actions, undo should be disabled + // (the initial Load Image creates one past state, so let's just + // check the button exists and is a proper control) + const undoBtn = page.locator("button[aria-label='Undo']"); + await expect(undoBtn).toBeVisible(); + + // Redo should be disabled since we haven't undone anything + const redoBtn = page.locator("button[aria-label='Redo']"); + await expect(redoBtn).toBeVisible(); + // Redo should visually appear disabled (no future states) + await expect(redoBtn).toHaveCSS("cursor", "not-allowed"); + }); +}); diff --git a/tests/e2e-editor/editor-keyboard-shortcuts.spec.ts b/tests/e2e-editor/editor-keyboard-shortcuts.spec.ts new file mode 100644 index 00000000..465ae66c --- /dev/null +++ b/tests/e2e-editor/editor-keyboard-shortcuts.spec.ts @@ -0,0 +1,95 @@ +import { createNewDocument, expect, selectTool, test } from "./helpers"; + +test.describe("Editor Keyboard Shortcuts", () => { + test.beforeEach(async ({ editorPage: page }) => { + await createNewDocument(page); + }); + + test("V activates move tool", async ({ editorPage: page }) => { + await selectTool(page, "brush"); // start elsewhere + await page.keyboard.press("v"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='move']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("B activates brush tool", async ({ editorPage: page }) => { + await page.keyboard.press("b"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='brush']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("E activates eraser tool", async ({ editorPage: page }) => { + await page.keyboard.press("e"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='eraser']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("C activates crop tool", async ({ editorPage: page }) => { + await page.keyboard.press("c"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='crop']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("M activates marquee tool", async ({ editorPage: page }) => { + await page.keyboard.press("m"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='marquee-rect']")).toHaveAttribute( + "data-tool-active", + "true", + ); + }); + + test("I activates eyedropper tool", async ({ editorPage: page }) => { + await page.keyboard.press("i"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='eyedropper']")).toHaveAttribute( + "data-tool-active", + "true", + ); + }); + + test("G activates fill tool", async ({ editorPage: page }) => { + await page.keyboard.press("g"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='fill']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("H activates hand tool", async ({ editorPage: page }) => { + await page.keyboard.press("h"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='hand']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("Z activates zoom tool", async ({ editorPage: page }) => { + await page.keyboard.press("z"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='zoom']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("shortcuts are disabled when typing in text input", async ({ editorPage: page }) => { + // Focus the zoom input in the status bar (a number input) + const zoomInput = page.locator("[data-testid='status-zoom'] input[type='number']"); + await zoomInput.click(); + await zoomInput.fill(""); + + // Pressing B while focused in input should NOT switch tool + const activeBefore = await page.locator("[data-tool-active='true']").getAttribute("data-tool"); + + await page.keyboard.press("b"); + await page.waitForTimeout(300); + + const activeAfter = await page.locator("[data-tool-active='true']").getAttribute("data-tool"); + + // Tool should not have changed to brush + expect(activeAfter).toBe(activeBefore); + }); +}); diff --git a/tests/e2e-editor/editor-layers.spec.ts b/tests/e2e-editor/editor-layers.spec.ts new file mode 100644 index 00000000..e249bd90 --- /dev/null +++ b/tests/e2e-editor/editor-layers.spec.ts @@ -0,0 +1,143 @@ +import { createNewDocument, expect, test } from "./helpers"; + +test.describe("Editor Layers Panel", () => { + test.beforeEach(async ({ editorPage: page }) => { + await createNewDocument(page); + // Ensure layers tab is active + await page.locator("[data-testid='tab-layers']").click(); + await page.waitForTimeout(300); + }); + + test("layers panel shows default Layer 1", async ({ editorPage: page }) => { + const layersPanel = page.locator("[data-testid='layers-panel']"); + await expect(layersPanel).toBeVisible(); + + // Should have at least one layer with "Layer 1" text + const layerRows = layersPanel.locator("[role='option']"); + await expect(layerRows).toHaveCount(1); + await expect(layersPanel.getByText("Layer 1")).toBeVisible(); + }); + + test("add layer button creates new layer", async ({ editorPage: page }) => { + const addBtn = page.locator("[data-testid='add-layer-btn']"); + await expect(addBtn).toBeVisible(); + + await addBtn.click(); + await page.waitForTimeout(300); + + // Now there should be 2 layers + const layersPanel = page.locator("[data-testid='layers-panel']"); + const layerRows = layersPanel.locator("[role='option']"); + await expect(layerRows).toHaveCount(2); + }); + + test("cannot delete last layer", async ({ editorPage: page }) => { + const deleteBtn = page.locator("[data-testid='delete-layer-btn']"); + await expect(deleteBtn).toBeVisible(); + + // With only one layer, delete button should be disabled + await expect(deleteBtn).toBeDisabled(); + }); + + test("delete button enabled with multiple layers", async ({ editorPage: page }) => { + // Add a second layer + await page.locator("[data-testid='add-layer-btn']").click(); + await page.waitForTimeout(300); + + const deleteBtn = page.locator("[data-testid='delete-layer-btn']"); + await expect(deleteBtn).toBeEnabled(); + }); + + test("eye icon toggles visibility", async ({ editorPage: page }) => { + const layersPanel = page.locator("[data-testid='layers-panel']"); + const layerRow = layersPanel.locator("[role='option']").first(); + + // Initially visible (Eye icon, aria-label "Hide layer") + const hideBtn = layerRow.locator("button[aria-label='Hide layer']"); + await expect(hideBtn).toBeVisible(); + + // Click to hide + await hideBtn.click(); + await page.waitForTimeout(300); + + // Now should show "Show layer" button + const showBtn = layerRow.locator("button[aria-label='Show layer']"); + await expect(showBtn).toBeVisible(); + + // Click again to show + await showBtn.click(); + await page.waitForTimeout(300); + + await expect(layerRow.locator("button[aria-label='Hide layer']")).toBeVisible(); + }); + + test("lock icon toggles lock state", async ({ editorPage: page }) => { + const layersPanel = page.locator("[data-testid='layers-panel']"); + const layerRow = layersPanel.locator("[role='option']").first(); + + // Initially unlocked + const lockBtn = layerRow.locator("button[aria-label='Lock layer']"); + await expect(lockBtn).toBeVisible(); + + await lockBtn.click(); + await page.waitForTimeout(300); + + // Now should show "Unlock layer" + const unlockBtn = layerRow.locator("button[aria-label='Unlock layer']"); + await expect(unlockBtn).toBeVisible(); + }); + + test("blend mode dropdown shows modes", async ({ editorPage: page }) => { + const blendSelect = page.locator("[data-testid='blend-mode-select']"); + await expect(blendSelect).toBeVisible(); + + // Check that it has the expected blend mode options + const options = blendSelect.locator("option"); + const texts = await options.allTextContents(); + + expect(texts).toContain("Normal"); + expect(texts).toContain("Multiply"); + expect(texts).toContain("Screen"); + expect(texts).toContain("Overlay"); + expect(texts).toContain("Darken"); + expect(texts).toContain("Lighten"); + }); + + test("opacity slider works", async ({ editorPage: page }) => { + const slider = page.locator("[data-testid='layer-opacity-slider']"); + await expect(slider).toBeVisible(); + + // Default opacity should be 100 + await expect(slider).toHaveValue("100"); + + // Change opacity + await slider.fill("50"); + await page.waitForTimeout(300); + + await expect(slider).toHaveValue("50"); + }); + + test("double-click layer name enters rename mode", async ({ editorPage: page }) => { + const layersPanel = page.locator("[data-testid='layers-panel']"); + + // Double-click the layer name button + const nameBtn = layersPanel + .locator("[role='option']") + .first() + .locator("button") + .filter({ hasText: "Layer 1" }); + await nameBtn.dblclick(); + await page.waitForTimeout(300); + + // An input for renaming should appear + const renameInput = layersPanel.locator("input[type='text'][class*='bg-muted']"); + await expect(renameInput).toBeVisible(); + }); + + test("layer row highlights active layer", async ({ editorPage: page }) => { + const layersPanel = page.locator("[data-testid='layers-panel']"); + const layerRow = layersPanel.locator("[role='option']").first(); + + await expect(layerRow).toHaveAttribute("aria-selected", "true"); + }); +}); diff --git a/tests/e2e-editor/editor-navigation.spec.ts b/tests/e2e-editor/editor-navigation.spec.ts new file mode 100644 index 00000000..a4d69f9a --- /dev/null +++ b/tests/e2e-editor/editor-navigation.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from "./helpers"; + +test.describe("Editor Navigation", () => { + test("sidebar shows Editor between Automate and Files", async ({ editorPage: page }) => { + await page.goto("/"); + await page.waitForTimeout(1000); + + const sidebarLinks = page.locator("nav a, aside a, [class*='sidebar'] a"); + const labels = await sidebarLinks.allTextContents(); + const flat = labels.join("|"); + + expect(flat).toContain("Automate"); + expect(flat).toContain("Editor"); + expect(flat).toContain("Files"); + + const automateIdx = flat.indexOf("Automate"); + const editorIdx = flat.indexOf("Editor"); + const filesIdx = flat.indexOf("Files"); + expect(editorIdx).toBeGreaterThan(automateIdx); + expect(filesIdx).toBeGreaterThan(editorIdx); + }); + + test("clicking Editor navigates to /editor", async ({ editorPage: page }) => { + await page.goto("/"); + await page.waitForTimeout(1000); + + await page.locator('a[href="/editor"]').click(); + await page.waitForTimeout(1000); + + expect(page.url()).toContain("/editor"); + }); + + test("editor page renders four-zone layout", async ({ editorPage: page }) => { + // Options bar at the top (contains tool name) + const optionsBar = page.locator("text=move").first(); + await expect(optionsBar).toBeVisible(); + + // Toolbar on the left (contains tool buttons with data-tool) + const toolbar = page.locator("[data-tool='move']"); + await expect(toolbar).toBeVisible(); + + // Canvas area in the middle + const canvasArea = page.locator(".bg-muted\\/30").first(); + await expect(canvasArea).toBeVisible(); + + // Right panel with tabs + const rightPanel = page.locator("[data-testid='tab-layers']"); + await expect(rightPanel).toBeVisible(); + }); + + test("welcome screen shows when no image loaded", async ({ editorPage: page }) => { + await expect(page.getByText("Image Editor")).toBeVisible(); + await expect(page.getByText("Drop an image here to get started")).toBeVisible(); + await expect(page.getByText("Open Image")).toBeVisible(); + await expect(page.getByText("New Document")).toBeVisible(); + await expect(page.getByText("paste from clipboard")).toBeVisible(); + }); + + test("right panel has three tabs", async ({ editorPage: page }) => { + const layersTab = page.locator("[data-testid='tab-layers']"); + const adjustmentsTab = page.locator("[data-testid='tab-adjustments']"); + const historyTab = page.locator("[data-testid='tab-history']"); + + await expect(layersTab).toBeVisible(); + await expect(adjustmentsTab).toBeVisible(); + await expect(historyTab).toBeVisible(); + + await expect(layersTab).toHaveText("Layers"); + await expect(adjustmentsTab).toHaveText("Adjustments"); + await expect(historyTab).toHaveText("History"); + }); + + test("right panel collapses and expands", async ({ editorPage: page }) => { + // Panel starts visible with tabs + await expect(page.locator("[data-testid='tab-layers']")).toBeVisible(); + + // Click the collapse button (ChevronRight at end of tab row) + await page.locator("button[aria-label='Collapse panel']").click(); + await page.waitForTimeout(300); + + // Tabs should no longer be visible + await expect(page.locator("[data-testid='tab-layers']")).not.toBeVisible(); + + // Expand button should appear + const expandBtn = page.locator("button[aria-label='Expand panel']"); + await expect(expandBtn).toBeVisible(); + + // Click expand + await expandBtn.click(); + await page.waitForTimeout(300); + + // Tabs visible again + await expect(page.locator("[data-testid='tab-layers']")).toBeVisible(); + }); + + test("status bar shows zoom level", async ({ editorPage: page }) => { + const statusZoom = page.locator("[data-testid='status-zoom']"); + await expect(statusZoom).toBeVisible(); + + // Should contain a number input and % sign + const zoomInput = statusZoom.locator("input[type='number']"); + await expect(zoomInput).toBeVisible(); + await expect(statusZoom.locator("text=%")).toBeVisible(); + }); + + test("status bar shows cursor position after loading document", async ({ editorPage: page }) => { + const { createNewDocument } = await import("./helpers"); + await createNewDocument(page); + + const statusCursor = page.locator("[data-testid='status-cursor']"); + await expect(statusCursor).toBeVisible(); + + // Should show X and Y values + await expect(page.locator("[data-testid='status-cursor-x']")).toBeVisible(); + await expect(page.locator("[data-testid='status-cursor-y']")).toBeVisible(); + }); +}); diff --git a/tests/e2e-editor/editor-tools-drawing.spec.ts b/tests/e2e-editor/editor-tools-drawing.spec.ts new file mode 100644 index 00000000..a37a4171 --- /dev/null +++ b/tests/e2e-editor/editor-tools-drawing.spec.ts @@ -0,0 +1,131 @@ +import { createNewDocument, drawOnCanvas, expect, selectTool, test } from "./helpers"; + +test.describe("Editor Drawing Tools", () => { + test.beforeEach(async ({ editorPage: page }) => { + await createNewDocument(page); + }); + + test("brush activates via toolbar click", async ({ editorPage: page }) => { + await selectTool(page, "brush"); + + const brushBtn = page.locator("[data-tool='brush']"); + await expect(brushBtn).toHaveAttribute("data-tool-active", "true"); + }); + + test("brush activates via B shortcut", async ({ editorPage: page }) => { + // Start with a different tool + await selectTool(page, "move"); + await expect(page.locator("[data-tool='move']")).toHaveAttribute("data-tool-active", "true"); + + // Press B to switch to brush + await page.keyboard.press("b"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='brush']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("brush options bar shows size, opacity, and hardness", async ({ editorPage: page }) => { + await selectTool(page, "brush"); + + // Options bar should display Size, Opacity, and Hardness labels + const optionsBar = page.locator(".flex.items-center.h-10"); + await expect(optionsBar.getByText("Size")).toBeVisible(); + await expect(optionsBar.getByText("Opacity")).toBeVisible(); + await expect(optionsBar.getByText("Hardness")).toBeVisible(); + }); + + test("brush drawing changes canvas content", async ({ editorPage: page }) => { + await selectTool(page, "brush"); + + const canvas = page.locator("canvas").first(); + const before = await canvas.screenshot(); + + await drawOnCanvas(page, 100, 100, 300, 300); + + const after = await canvas.screenshot(); + expect(Buffer.compare(before, after)).not.toBe(0); + }); + + test("shape tool draws rectangle on canvas", async ({ editorPage: page }) => { + await selectTool(page, "shape-rect"); + + const canvas = page.locator("canvas").first(); + const before = await canvas.screenshot(); + + await drawOnCanvas(page, 100, 100, 250, 200); + + const after = await canvas.screenshot(); + expect(Buffer.compare(before, after)).not.toBe(0); + }); + + test("shape tool draws ellipse on canvas", async ({ editorPage: page }) => { + await selectTool(page, "shape-rect"); + + // Switch to ellipse via the shape type dropdown in options bar + const shapeSelect = page.locator("select").filter({ hasText: "Rectangle" }); + await shapeSelect.selectOption("shape-ellipse"); + await page.waitForTimeout(300); + + const canvas = page.locator("canvas").first(); + const before = await canvas.screenshot(); + + await drawOnCanvas(page, 150, 150, 350, 300); + + const after = await canvas.screenshot(); + expect(Buffer.compare(before, after)).not.toBe(0); + }); + + test("eraser activates via E shortcut", async ({ editorPage: page }) => { + await page.keyboard.press("e"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='eraser']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("eraser options bar shows size and opacity", async ({ editorPage: page }) => { + await selectTool(page, "eraser"); + + const optionsBar = page.locator(".flex.items-center.h-10"); + await expect(optionsBar.getByText("Size")).toBeVisible(); + await expect(optionsBar.getByText("Opacity")).toBeVisible(); + }); + + test("pencil tool activates via N shortcut", async ({ editorPage: page }) => { + await page.keyboard.press("n"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='pencil']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("pencil options bar hides hardness", async ({ editorPage: page }) => { + await selectTool(page, "pencil"); + + const optionsBar = page.locator(".flex.items-center.h-10"); + await expect(optionsBar.getByText("Size")).toBeVisible(); + await expect(optionsBar.getByText("Opacity")).toBeVisible(); + // Pencil is always hard, so hardness should not appear + await expect(optionsBar.getByText("Hardness")).not.toBeVisible(); + }); + + test("shape fill color applies", async ({ editorPage: page }) => { + await selectTool(page, "shape-rect"); + + // The fill color input should be visible in options bar + const fillLabel = page.locator("label").filter({ hasText: "Fill" }); + await expect(fillLabel).toBeVisible(); + + // A color input for fill should be present + const fillColorInput = fillLabel.locator("input[type='color']"); + await expect(fillColorInput).toBeVisible(); + }); + + test("shape stroke width control is visible", async ({ editorPage: page }) => { + await selectTool(page, "shape-rect"); + + const widthLabel = page.locator("label").filter({ hasText: /^Width/ }); + await expect(widthLabel).toBeVisible(); + + const widthSlider = widthLabel.locator("input[type='range']"); + await expect(widthSlider).toBeVisible(); + }); +}); diff --git a/tests/e2e-editor/editor-tools-selection.spec.ts b/tests/e2e-editor/editor-tools-selection.spec.ts new file mode 100644 index 00000000..13486e8e --- /dev/null +++ b/tests/e2e-editor/editor-tools-selection.spec.ts @@ -0,0 +1,90 @@ +import { createNewDocument, expect, selectTool, test } from "./helpers"; + +test.describe("Editor Selection Tools", () => { + test.beforeEach(async ({ editorPage: page }) => { + await createNewDocument(page); + }); + + test("move tool activates via V shortcut", async ({ editorPage: page }) => { + // Start with a different tool + await selectTool(page, "brush"); + + await page.keyboard.press("v"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='move']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("move tool shows options bar label", async ({ editorPage: page }) => { + await selectTool(page, "move"); + + // Options bar should show "move" label + const optionsBar = page.locator(".flex.items-center.h-10"); + await expect(optionsBar.getByText("move", { exact: false })).toBeVisible(); + }); + + test("marquee activates via M shortcut", async ({ editorPage: page }) => { + await page.keyboard.press("m"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='marquee-rect']")).toHaveAttribute( + "data-tool-active", + "true", + ); + }); + + test("marquee cycles subtypes on repeated M press", async ({ editorPage: page }) => { + await page.keyboard.press("m"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='marquee-rect']")).toHaveAttribute( + "data-tool-active", + "true", + ); + + // Press M again to cycle to ellipse marquee + await page.keyboard.press("m"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='marquee-rect']")).toHaveAttribute( + "data-tool-active", + "false", + ); + }); + + test("crop activates via C shortcut", async ({ editorPage: page }) => { + await page.keyboard.press("c"); + await page.waitForTimeout(300); + + await expect(page.locator("[data-tool='crop']")).toHaveAttribute("data-tool-active", "true"); + }); + + test("crop options show aspect ratio dropdown", async ({ editorPage: page }) => { + await selectTool(page, "crop"); + + // Crop options bar should have aspect ratio dropdown + const ratioLabel = page.getByText("Ratio:"); + await expect(ratioLabel).toBeVisible(); + + const ratioSelect = page.locator("#crop-aspect"); + await expect(ratioSelect).toBeVisible(); + + // The dropdown should have a "Free" option + const freeOption = ratioSelect.locator("option", { hasText: "Free" }); + await expect(freeOption).toBeAttached(); + }); + + test("crop options show width and height inputs", async ({ editorPage: page }) => { + await selectTool(page, "crop"); + + await expect(page.locator("#crop-width")).toBeVisible(); + await expect(page.locator("#crop-height")).toBeVisible(); + }); + + test("crop options show apply and cancel buttons", async ({ editorPage: page }) => { + await selectTool(page, "crop"); + + await expect(page.locator("button[aria-label='Apply Crop']")).toBeVisible(); + await expect(page.locator("button[aria-label='Cancel Crop']")).toBeVisible(); + }); +}); diff --git a/tests/e2e-editor/helpers.ts b/tests/e2e-editor/helpers.ts new file mode 100644 index 00000000..283cf7d0 --- /dev/null +++ b/tests/e2e-editor/helpers.ts @@ -0,0 +1,53 @@ +import { test as base, expect, type Page } from "@playwright/test"; + +export const test = base.extend<{ editorPage: Page }>({ + editorPage: async ({ page }, use) => { + // Login first + await page.goto("/login"); + await page.waitForTimeout(1500); + 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(1500); + // Navigate to editor + await page.goto("/editor"); + await page.waitForTimeout(2000); + await use(page); + }, +}); + +export { expect }; + +export async function createNewDocument(page: Page, _width = 1920, _height = 1080): Promise { + await page.getByText("New Document").click(); + await page.waitForTimeout(500); + await page.locator('button:has-text("Create")').click(); + await page.waitForTimeout(2000); +} + +export async function waitForCanvas(page: Page): Promise { + await page.locator("canvas").first().waitFor({ state: "visible", timeout: 10000 }); +} + +export async function selectTool(page: Page, toolName: string): Promise { + await page.locator(`[data-tool="${toolName}"]`).click(); + await page.waitForTimeout(300); +} + +export async function drawOnCanvas( + page: Page, + x1: number, + y1: number, + x2: number, + y2: number, +): Promise { + const canvas = page.locator("canvas").first(); + const box = await canvas.boundingBox(); + if (!box) throw new Error("Canvas not found"); + await page.mouse.move(box.x + x1, box.y + y1); + await page.mouse.down(); + await page.mouse.move(box.x + x2, box.y + y2, { steps: 10 }); + await page.mouse.up(); + await page.waitForTimeout(300); +}