mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test: add editor E2E test specs
This commit is contained in:
@@ -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"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
await page.locator("canvas").first().waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectTool(page: Page, toolName: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user