mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
merge: resolve conflict with main branch in README.md
This commit is contained in:
@@ -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,711 @@
|
||||
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
|
||||
await expect(page.getByText("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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import path from "node:path";
|
||||
import { expect, test, uploadTestImage, waitForProcessing } from "./helpers";
|
||||
|
||||
const fixtureFormat = (name: string) =>
|
||||
path.join(process.cwd(), "tests", "fixtures", "formats", name);
|
||||
const fixtureRoot = (name: string) => path.join(process.cwd(), "tests", "fixtures", name);
|
||||
|
||||
async function uploadFixture(page: import("@playwright/test").Page, filePath: string) {
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.locator("[class*='border-dashed']").first().click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(filePath);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe("Format upload and resize processing", () => {
|
||||
test.describe.configure({ timeout: 60_000 });
|
||||
const formats: [string, string][] = [
|
||||
["PNG", "sample.png"],
|
||||
["JPEG", "sample.jpg"],
|
||||
["WebP", "sample.webp"],
|
||||
["BMP", "sample.bmp"],
|
||||
["AVIF", "sample.avif"],
|
||||
["GIF", "sample.gif"],
|
||||
["SVG", "sample.svg"],
|
||||
["TIFF", "sample.tiff"],
|
||||
];
|
||||
for (const [label, fileName] of formats) {
|
||||
test(`${label} uploads and resizes`, async ({ loggedInPage: page }) => {
|
||||
await page.goto("/resize");
|
||||
await uploadFixture(page, fixtureFormat(fileName));
|
||||
await page.locator("input[placeholder='Auto']").first().fill("25");
|
||||
await page.getByRole("button", { name: "Resize" }).click();
|
||||
await waitForProcessing(page);
|
||||
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
}
|
||||
test("HEIC uploads and resizes", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/resize");
|
||||
await uploadFixture(page, fixtureRoot("test-200x150.heic"));
|
||||
await page.locator("input[placeholder='Auto']").first().fill("25");
|
||||
await page.getByRole("button", { name: "Resize" }).click();
|
||||
await waitForProcessing(page);
|
||||
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Convert tool - new output formats", () => {
|
||||
test.describe.configure({ timeout: 60_000 });
|
||||
for (const fmt of ["bmp", "ico", "jp2", "qoi", "jxl"]) {
|
||||
test(`converts PNG to ${fmt.toUpperCase()}`, async ({ loggedInPage: page }) => {
|
||||
await page.goto("/convert");
|
||||
await uploadTestImage(page);
|
||||
await page.selectOption("#convert-target-format", fmt);
|
||||
await page.getByRole("button", { name: /convert/i }).click();
|
||||
await waitForProcessing(page);
|
||||
const ok = await page
|
||||
.getByRole("link", { name: /download/i })
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 15_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const err = !ok
|
||||
? await page
|
||||
.getByText(/error|unsupported|failed|not available/i)
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
: false;
|
||||
expect(ok || err, `${fmt}: expected download or error`).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Convert tool - format dropdown", () => {
|
||||
test("contains all 13 output formats", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/convert");
|
||||
await page.waitForSelector("#convert-target-format", { timeout: 10_000 });
|
||||
const options = await page
|
||||
.locator("#convert-target-format option")
|
||||
.evaluateAll((els) => els.map((el) => (el as HTMLOptionElement).value));
|
||||
for (const fmt of [
|
||||
"jpg",
|
||||
"png",
|
||||
"webp",
|
||||
"avif",
|
||||
"tiff",
|
||||
"gif",
|
||||
"heic",
|
||||
"heif",
|
||||
"jxl",
|
||||
"bmp",
|
||||
"ico",
|
||||
"jp2",
|
||||
"qoi",
|
||||
]) {
|
||||
expect(options, `missing: ${fmt}`).toContain(fmt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("HEIC-to-JPG conversion", () => {
|
||||
test.describe.configure({ timeout: 60_000 });
|
||||
test("uploads HEIC, converts to JPG", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/convert");
|
||||
await uploadFixture(page, fixtureRoot("test-200x150.heic"));
|
||||
await expect(page.getByText(/heic/i).first()).toBeVisible({ timeout: 10_000 });
|
||||
await page.selectOption("#convert-target-format", "jpg");
|
||||
await page.getByRole("button", { name: /convert/i }).click();
|
||||
await waitForProcessing(page);
|
||||
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Exotic format upload acceptance", () => {
|
||||
test.describe.configure({ timeout: 60_000 });
|
||||
const exoticFormats: [string, string][] = [
|
||||
["SVGZ", "sample.svgz"],
|
||||
["EPS", "sample.eps"],
|
||||
["PPM", "sample.ppm"],
|
||||
["PGM", "sample.pgm"],
|
||||
["PBM", "sample.pbm"],
|
||||
["CUR", "sample.cur"],
|
||||
["APNG", "sample.apng"],
|
||||
];
|
||||
for (const [label, fileName] of exoticFormats) {
|
||||
test(`${label} uploads to info without crashing`, async ({ loggedInPage: page }) => {
|
||||
await page.goto("/info");
|
||||
await uploadFixture(page, fixtureFormat(fileName));
|
||||
await page.getByRole("button", { name: /read info/i }).click();
|
||||
await waitForProcessing(page);
|
||||
const meta = await page
|
||||
.getByText(/width|height|format|dimensions|size|resolution|channels|pixel/i)
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 15_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const err = !meta
|
||||
? await page
|
||||
.getByText(/error|unsupported|failed|not supported|cannot|invalid/i)
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
: false;
|
||||
expect(meta || err, `${label}: expected metadata or error`).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 511 KiB |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+1735
File diff suppressed because one or more lines are too long
Vendored
+5146
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1000,7 +1000,7 @@ describe("Tool processing", () => {
|
||||
it("rejects unsupported format", async () => {
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "convert.png", contentType: "image/png", content: PNG_1x1 },
|
||||
{ name: "settings", content: JSON.stringify({ format: "bmp" }) },
|
||||
{ name: "settings", content: JSON.stringify({ format: "xyz" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
|
||||
@@ -179,6 +179,102 @@ const FORMAT_SAMPLES: FormatSample[] = [
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: true,
|
||||
},
|
||||
{
|
||||
name: "SVGZ",
|
||||
file: "sample.svgz",
|
||||
mime: "image/svg+xml",
|
||||
needsCliDecoder: false,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "JP2",
|
||||
file: "sample.jp2",
|
||||
mime: "image/jp2",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "EPS",
|
||||
file: "sample.eps",
|
||||
mime: "application/postscript",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "PPM",
|
||||
file: "sample.ppm",
|
||||
mime: "image/x-portable-pixmap",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "PGM",
|
||||
file: "sample.pgm",
|
||||
mime: "image/x-portable-graymap",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "PBM",
|
||||
file: "sample.pbm",
|
||||
mime: "image/x-portable-bitmap",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "DDS",
|
||||
file: "sample.dds",
|
||||
mime: "image/vnd.ms-dds",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "CUR",
|
||||
file: "sample.cur",
|
||||
mime: "image/x-icon",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "DPX",
|
||||
file: "sample.dpx",
|
||||
mime: "image/x-dpx",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "FITS",
|
||||
file: "sample.fits",
|
||||
mime: "image/fits",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "APNG",
|
||||
file: "sample.apng",
|
||||
mime: "image/apng",
|
||||
needsCliDecoder: false,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "QOI",
|
||||
file: "sample.qoi",
|
||||
mime: "image/x-qoi",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Integration tests for new input/output format support.
|
||||
*
|
||||
* - New output formats: convert PNG to jxl, bmp, ico, jp2, qoi
|
||||
* - SVGZ input: verify compressed SVG decodes correctly
|
||||
* - JXL quality: verify lower quality produces smaller files
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const FORMATS_DIR = join(__dirname, "..", "fixtures", "formats");
|
||||
|
||||
describe("New format support", () => {
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp?.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New output format conversions
|
||||
// ---------------------------------------------------------------------------
|
||||
const NEW_OUTPUT_FORMATS = ["jxl", "bmp", "ico", "jp2", "qoi"];
|
||||
|
||||
for (const format of NEW_OUTPUT_FORMATS) {
|
||||
it(`converts PNG to ${format}`, async () => {
|
||||
const fileBuffer = readFileSync(join(FORMATS_DIR, "sample.png"));
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "sample.png",
|
||||
contentType: "image/png",
|
||||
content: fileBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ format, quality: 80 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/convert",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
// Accept 200 (success) or 422 (encoder not available in test env)
|
||||
expect([200, 422]).toContain(res.statusCode);
|
||||
if (res.statusCode === 200) {
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.processedSize).toBeGreaterThan(0);
|
||||
expect(json.downloadUrl).toBeTruthy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVGZ input decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
it("decodes SVGZ input correctly", async () => {
|
||||
const fileBuffer = readFileSync(join(FORMATS_DIR, "sample.svgz"));
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "sample.svgz",
|
||||
contentType: "image/svg+xml",
|
||||
content: fileBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ format: "png" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/convert",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect([200, 422]).toContain(res.statusCode);
|
||||
if (res.statusCode === 200) {
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.processedSize).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JXL quality affects file size
|
||||
// ---------------------------------------------------------------------------
|
||||
it("JXL quality affects file size", async () => {
|
||||
const fileBuffer = readFileSync(join(FORMATS_DIR, "sample.png"));
|
||||
|
||||
const convert = async (quality: number) => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "sample.png",
|
||||
contentType: "image/png",
|
||||
content: fileBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ format: "jxl", quality }),
|
||||
},
|
||||
]);
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/convert",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
};
|
||||
|
||||
const lowQ = await convert(30);
|
||||
const highQ = await convert(90);
|
||||
|
||||
// Only compare sizes if both succeeded (JXL encoder may not be available)
|
||||
if (lowQ.statusCode === 200 && highQ.statusCode === 200) {
|
||||
const lowJson = JSON.parse(lowQ.body);
|
||||
const highJson = JSON.parse(highQ.body);
|
||||
expect(lowJson.processedSize).toBeLessThan(highJson.processedSize);
|
||||
}
|
||||
});
|
||||
|
||||
describe("Extended output format matrix", () => {
|
||||
const INPUTS = [
|
||||
{ name: "PNG", file: "sample.png", mime: "image/png" },
|
||||
{ name: "JPEG", file: "sample.jpg", mime: "image/jpeg" },
|
||||
{ name: "WebP", file: "sample.webp", mime: "image/webp" },
|
||||
];
|
||||
const OUTPUTS = [
|
||||
"jpg",
|
||||
"png",
|
||||
"webp",
|
||||
"avif",
|
||||
"tiff",
|
||||
"gif",
|
||||
"heic",
|
||||
"heif",
|
||||
"jxl",
|
||||
"bmp",
|
||||
"ico",
|
||||
"jp2",
|
||||
"qoi",
|
||||
];
|
||||
|
||||
for (const input of INPUTS) {
|
||||
for (const outFmt of OUTPUTS) {
|
||||
const inLower = input.name.toLowerCase();
|
||||
if (inLower === outFmt || (inLower === "jpeg" && outFmt === "jpg")) continue;
|
||||
it(`converts ${input.name} to ${outFmt}`, async () => {
|
||||
const fileBuffer = readFileSync(join(FORMATS_DIR, input.file));
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: input.file, contentType: input.mime, content: fileBuffer },
|
||||
{ name: "settings", content: JSON.stringify({ format: outFmt, quality: 80 }) },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/convert",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
expect([200, 422]).toContain(res.statusCode);
|
||||
if (res.statusCode === 200) {
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.processedSize).toBeGreaterThan(0);
|
||||
expect(json.downloadUrl).toBeTruthy();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("New input format processing via resize", () => {
|
||||
const NEW_INPUT_FORMATS = [
|
||||
{ name: "SVGZ", file: "sample.svgz", mime: "image/svg+xml" },
|
||||
{ name: "JP2", file: "sample.jp2", mime: "image/jp2" },
|
||||
{ name: "EPS", file: "sample.eps", mime: "application/postscript" },
|
||||
{ name: "PPM", file: "sample.ppm", mime: "image/x-portable-pixmap" },
|
||||
{ name: "PGM", file: "sample.pgm", mime: "image/x-portable-graymap" },
|
||||
{ name: "PBM", file: "sample.pbm", mime: "image/x-portable-bitmap" },
|
||||
{ name: "DDS", file: "sample.dds", mime: "image/vnd.ms-dds" },
|
||||
{ name: "CUR", file: "sample.cur", mime: "image/x-icon" },
|
||||
{ name: "DPX", file: "sample.dpx", mime: "image/x-dpx" },
|
||||
{ name: "FITS", file: "sample.fits", mime: "image/fits" },
|
||||
{ name: "APNG", file: "sample.apng", mime: "image/apng" },
|
||||
{ name: "QOI", file: "sample.qoi", mime: "image/x-qoi" },
|
||||
];
|
||||
for (const fmt of NEW_INPUT_FORMATS) {
|
||||
it(`resizes ${fmt.name} input to 25x25`, async () => {
|
||||
const fixturePath = join(FORMATS_DIR, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
const fileBuffer = readFileSync(fixturePath);
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: fmt.file, contentType: fmt.mime, content: fileBuffer },
|
||||
{ name: "settings", content: JSON.stringify({ width: 25, height: 25 }) },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/resize",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
expect([200, 400, 422]).toContain(res.statusCode);
|
||||
if (res.statusCode === 200) {
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.downloadUrl).toBeTruthy();
|
||||
expect(json.processedSize).toBeGreaterThan(0);
|
||||
}
|
||||
}, 60_000);
|
||||
}
|
||||
});
|
||||
|
||||
describe("Preview generation for special formats", () => {
|
||||
const PREVIEW_FORMATS = [
|
||||
{ name: "HEIC", file: "sample.heic", mime: "image/heic" },
|
||||
{ name: "JXL", file: "sample.jxl", mime: "image/jxl" },
|
||||
{ name: "ICO", file: "sample.ico", mime: "image/x-icon" },
|
||||
{ name: "PSD", file: "sample.psd", mime: "image/vnd.adobe.photoshop" },
|
||||
{ name: "EXR", file: "sample.exr", mime: "image/x-exr" },
|
||||
];
|
||||
for (const fmt of PREVIEW_FORMATS) {
|
||||
it(`generates preview for ${fmt.name}`, async () => {
|
||||
const fixturePath = join(FORMATS_DIR, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
const fileBuffer = readFileSync(fixturePath);
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: fmt.file, contentType: fmt.mime, content: fileBuffer },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/preview",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
expect([200, 422]).toContain(res.statusCode);
|
||||
if (res.statusCode === 200) {
|
||||
const ct = res.headers["content-type"] as string;
|
||||
expect(ct).toMatch(/image\/(webp|png)/);
|
||||
}
|
||||
}, 60_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -69,11 +69,38 @@ describe("needsCliDecode", () => {
|
||||
it("returns false for unknown format", () => {
|
||||
expect(needsCliDecode("xyz")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// decodeToSharpCompat — routing logic (default passthrough)
|
||||
// ==========================================================================
|
||||
it("returns true for jp2 format", () => {
|
||||
expect(needsCliDecode("jp2")).toBe(true);
|
||||
});
|
||||
it("returns true for eps format", () => {
|
||||
expect(needsCliDecode("eps")).toBe(true);
|
||||
});
|
||||
it("returns true for dds format", () => {
|
||||
expect(needsCliDecode("dds")).toBe(true);
|
||||
});
|
||||
it("returns true for cur format", () => {
|
||||
expect(needsCliDecode("cur")).toBe(true);
|
||||
});
|
||||
it("returns true for dpx format", () => {
|
||||
expect(needsCliDecode("dpx")).toBe(true);
|
||||
});
|
||||
it("returns true for fits format", () => {
|
||||
expect(needsCliDecode("fits")).toBe(true);
|
||||
});
|
||||
it("returns true for qoi format", () => {
|
||||
expect(needsCliDecode("qoi")).toBe(true);
|
||||
});
|
||||
it("returns true for ppm format", () => {
|
||||
expect(needsCliDecode("ppm")).toBe(true);
|
||||
});
|
||||
it("returns true for pgm format", () => {
|
||||
expect(needsCliDecode("pgm")).toBe(true);
|
||||
});
|
||||
it("returns true for pbm format", () => {
|
||||
expect(needsCliDecode("pbm")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeToSharpCompat", () => {
|
||||
it("returns buffer unchanged for unknown/native formats", async () => {
|
||||
|
||||
@@ -238,5 +238,60 @@ describe("detectFormat", () => {
|
||||
const format = await detectFormat(Buffer.from([0x89]));
|
||||
expect(format).toBe("unknown");
|
||||
});
|
||||
|
||||
it("detects DDS magic bytes", async () => {
|
||||
const buf = Buffer.from([0x44, 0x44, 0x53, 0x20, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
expect(await detectFormat(buf)).toBe("dds");
|
||||
});
|
||||
|
||||
it("detects CUR magic bytes", async () => {
|
||||
const buf = Buffer.from([0x00, 0x00, 0x02, 0x00, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
expect(await detectFormat(buf)).toBe("cur");
|
||||
});
|
||||
|
||||
it("detects DPX forward magic bytes", async () => {
|
||||
const buf = Buffer.from([0x53, 0x44, 0x50, 0x58, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
expect(await detectFormat(buf)).toBe("dpx");
|
||||
});
|
||||
|
||||
it("detects FITS magic bytes", async () => {
|
||||
const buf = Buffer.from([0x53, 0x49, 0x4d, 0x50, 0x4c, 0x45, 0, 0, 0, 0, 0, 0]);
|
||||
expect(await detectFormat(buf)).toBe("fits");
|
||||
});
|
||||
|
||||
it("detects EPS ASCII magic bytes", async () => {
|
||||
const buf = Buffer.from([0x25, 0x21, 0x50, 0x53, 0x2d, 0x41, 0x64, 0x6f, 0x62, 0x65, 0, 0]);
|
||||
expect(await detectFormat(buf)).toBe("eps");
|
||||
});
|
||||
|
||||
it("detects EPS DOS binary magic bytes", async () => {
|
||||
const buf = Buffer.from([0xc5, 0xd0, 0xd3, 0xc6, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
expect(await detectFormat(buf)).toBe("eps");
|
||||
});
|
||||
|
||||
it("detects QOI magic bytes", async () => {
|
||||
const buf = Buffer.from([0x71, 0x6f, 0x69, 0x66, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
expect(await detectFormat(buf)).toBe("qoi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("real fixture format detection", () => {
|
||||
const fixtures: Array<{ file: string; expected: string[] }> = [
|
||||
{ file: "sample.dds", expected: ["dds", "unknown"] },
|
||||
{ file: "sample.cur", expected: ["cur", "ico", "unknown"] },
|
||||
{ file: "sample.dpx", expected: ["dpx", "unknown"] },
|
||||
{ file: "sample.fits", expected: ["fits", "unknown"] },
|
||||
{ file: "sample.eps", expected: ["eps", "unknown"] },
|
||||
{ file: "sample.qoi", expected: ["qoi", "unknown"] },
|
||||
{ file: "sample.apng", expected: ["png"] },
|
||||
];
|
||||
|
||||
for (const { file, expected } of fixtures) {
|
||||
it(`detects ${file}`, async () => {
|
||||
const buffer = readFileSync(path.join(FORMATS_DIR, file));
|
||||
const format = await detectFormat(buffer);
|
||||
expect(expected).toContain(format);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { qoiDecode, qoiEncode } from "@snapotter/image-engine";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const FORMATS_DIR = path.resolve(__dirname, "../../fixtures/formats");
|
||||
|
||||
describe("QOI codec", () => {
|
||||
it("round-trips RGBA pixel data", () => {
|
||||
const w = 4,
|
||||
h = 4;
|
||||
const pixels = new Uint8Array(w * h * 4);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
pixels[i * 4] = (i * 17) & 0xff;
|
||||
pixels[i * 4 + 1] = (i * 31) & 0xff;
|
||||
pixels[i * 4 + 2] = (i * 53) & 0xff;
|
||||
pixels[i * 4 + 3] = 255;
|
||||
}
|
||||
const encoded = qoiEncode(pixels, w, h, 4);
|
||||
const { header, pixels: decoded } = qoiDecode(encoded);
|
||||
expect(header.width).toBe(w);
|
||||
expect(header.height).toBe(h);
|
||||
for (let i = 0; i < w * h * 4; i++) expect(decoded[i]).toBe(pixels[i]);
|
||||
});
|
||||
|
||||
it("encodes 3-channel data", () => {
|
||||
const w = 3,
|
||||
h = 3;
|
||||
const pixels = new Uint8Array(w * h * 3);
|
||||
for (let i = 0; i < pixels.length; i++) pixels[i] = (i * 41) & 0xff;
|
||||
const encoded = qoiEncode(pixels, w, h, 3);
|
||||
const { header, pixels: decoded } = qoiDecode(encoded);
|
||||
expect(header.channels).toBe(3);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
expect(decoded[i * 4 + 3]).toBe(255);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes correct header magic", () => {
|
||||
const encoded = qoiEncode(new Uint8Array(4), 1, 1, 4);
|
||||
const view = new DataView(encoded.buffer, encoded.byteOffset);
|
||||
expect(view.getUint32(0)).toBe(0x716f6966);
|
||||
});
|
||||
|
||||
it("writes correct header dimensions", () => {
|
||||
const encoded = qoiEncode(new Uint8Array(20 * 15 * 4), 20, 15, 4);
|
||||
const view = new DataView(encoded.buffer, encoded.byteOffset);
|
||||
expect(view.getUint32(4)).toBe(20);
|
||||
expect(view.getUint32(8)).toBe(15);
|
||||
expect(encoded[12]).toBe(4);
|
||||
});
|
||||
|
||||
it("round-trips 1x1 image", () => {
|
||||
const pixels = new Uint8Array([42, 128, 200, 255]);
|
||||
const { pixels: decoded } = qoiDecode(qoiEncode(pixels, 1, 1, 4));
|
||||
expect(decoded[0]).toBe(42);
|
||||
expect(decoded[3]).toBe(255);
|
||||
});
|
||||
|
||||
it("round-trips solid color image", () => {
|
||||
const w = 8,
|
||||
h = 8;
|
||||
const pixels = new Uint8Array(w * h * 4);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
pixels[i * 4] = 100;
|
||||
pixels[i * 4 + 1] = 150;
|
||||
pixels[i * 4 + 2] = 200;
|
||||
pixels[i * 4 + 3] = 255;
|
||||
}
|
||||
const { pixels: decoded } = qoiDecode(qoiEncode(pixels, w, h, 4));
|
||||
for (let i = 0; i < pixels.length; i++) expect(decoded[i]).toBe(pixels[i]);
|
||||
});
|
||||
|
||||
it("compresses solid color efficiently", () => {
|
||||
const w = 100,
|
||||
h = 100;
|
||||
const pixels = new Uint8Array(w * h * 4);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
pixels[i * 4] = 50;
|
||||
pixels[i * 4 + 1] = 100;
|
||||
pixels[i * 4 + 2] = 150;
|
||||
pixels[i * 4 + 3] = 255;
|
||||
}
|
||||
const encoded = qoiEncode(pixels, w, h, 4);
|
||||
expect(encoded.length).toBeLessThan(pixels.length / 10);
|
||||
});
|
||||
|
||||
it("round-trips gradient", () => {
|
||||
const w = 16,
|
||||
h = 1;
|
||||
const pixels = new Uint8Array(w * 4);
|
||||
for (let i = 0; i < w; i++) {
|
||||
const v = Math.round((i / (w - 1)) * 255);
|
||||
pixels[i * 4] = v;
|
||||
pixels[i * 4 + 1] = v;
|
||||
pixels[i * 4 + 2] = v;
|
||||
pixels[i * 4 + 3] = 255;
|
||||
}
|
||||
const { pixels: decoded } = qoiDecode(qoiEncode(pixels, w, h, 4));
|
||||
for (let i = 0; i < pixels.length; i++) expect(decoded[i]).toBe(pixels[i]);
|
||||
});
|
||||
|
||||
it("round-trips random-ish data", () => {
|
||||
const w = 10,
|
||||
h = 10;
|
||||
const pixels = new Uint8Array(w * h * 4);
|
||||
for (let i = 0; i < pixels.length; i++) pixels[i] = (i * 97 + 53) & 0xff;
|
||||
const { pixels: decoded } = qoiDecode(qoiEncode(pixels, w, h, 4));
|
||||
for (let i = 0; i < pixels.length; i++) expect(decoded[i]).toBe(pixels[i]);
|
||||
});
|
||||
|
||||
it("round-trips varying alpha", () => {
|
||||
const pixels = new Uint8Array([
|
||||
100, 150, 200, 0, 100, 150, 200, 128, 100, 150, 200, 255, 0, 0, 0, 0,
|
||||
]);
|
||||
const { pixels: decoded } = qoiDecode(qoiEncode(pixels, 4, 1, 4));
|
||||
for (let i = 0; i < pixels.length; i++) expect(decoded[i]).toBe(pixels[i]);
|
||||
});
|
||||
|
||||
it("throws on invalid magic", () => {
|
||||
expect(() => qoiDecode(new Uint8Array(20))).toThrow();
|
||||
});
|
||||
|
||||
it("throws on zero width", () => {
|
||||
const buf = new Uint8Array(14);
|
||||
const v = new DataView(buf.buffer);
|
||||
v.setUint32(0, 0x716f6966);
|
||||
v.setUint32(4, 0);
|
||||
v.setUint32(8, 1);
|
||||
buf[12] = 4;
|
||||
expect(() => qoiDecode(buf)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on zero height", () => {
|
||||
const buf = new Uint8Array(14);
|
||||
const v = new DataView(buf.buffer);
|
||||
v.setUint32(0, 0x716f6966);
|
||||
v.setUint32(4, 1);
|
||||
v.setUint32(8, 0);
|
||||
buf[12] = 4;
|
||||
expect(() => qoiDecode(buf)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on invalid channels", () => {
|
||||
const buf = new Uint8Array(14);
|
||||
const v = new DataView(buf.buffer);
|
||||
v.setUint32(0, 0x716f6966);
|
||||
v.setUint32(4, 1);
|
||||
v.setUint32(8, 1);
|
||||
buf[12] = 5;
|
||||
expect(() => qoiDecode(buf)).toThrow();
|
||||
});
|
||||
|
||||
it("ends with correct end marker", () => {
|
||||
const encoded = qoiEncode(new Uint8Array([1, 2, 3, 255]), 1, 1, 4);
|
||||
expect(Array.from(encoded.slice(-8))).toEqual([0, 0, 0, 0, 0, 0, 0, 1]);
|
||||
});
|
||||
|
||||
it("decodes real fixture with correct header", () => {
|
||||
const data = readFileSync(path.join(FORMATS_DIR, "sample.qoi"));
|
||||
const { header } = qoiDecode(new Uint8Array(data));
|
||||
expect(header.width).toBe(10);
|
||||
expect(header.height).toBe(10);
|
||||
expect(header.channels).toBe(4);
|
||||
});
|
||||
|
||||
it("re-encodes real fixture with matching pixels", () => {
|
||||
const data = readFileSync(path.join(FORMATS_DIR, "sample.qoi"));
|
||||
const { header, pixels } = qoiDecode(new Uint8Array(data));
|
||||
const reEncoded = qoiEncode(pixels, header.width, header.height, 4);
|
||||
const { pixels: reDecoded } = qoiDecode(reEncoded);
|
||||
for (let i = 0; i < pixels.length; i++) expect(reDecoded[i]).toBe(pixels[i]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
CanvasObject,
|
||||
EllipseAttrs,
|
||||
LineAttrs,
|
||||
RectAttrs,
|
||||
TextAttrs,
|
||||
ToolType,
|
||||
} from "@/types/editor";
|
||||
|
||||
describe("CanvasObject discriminated union", () => {
|
||||
it("narrows to line type with LineAttrs", () => {
|
||||
const obj: CanvasObject = {
|
||||
id: "l1",
|
||||
type: "line",
|
||||
layerId: "layer-1",
|
||||
attrs: {
|
||||
points: [0, 0, 100, 100],
|
||||
stroke: "#000",
|
||||
strokeWidth: 2,
|
||||
tension: 0,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
opacity: 1,
|
||||
globalCompositeOperation: "source-over",
|
||||
},
|
||||
};
|
||||
if (obj.type === "line") {
|
||||
const attrs: LineAttrs = obj.attrs;
|
||||
expect(attrs.points).toEqual([0, 0, 100, 100]);
|
||||
expect(attrs.stroke).toBe("#000");
|
||||
expect(attrs.strokeWidth).toBe(2);
|
||||
} else {
|
||||
expect.unreachable("should have narrowed to line");
|
||||
}
|
||||
});
|
||||
|
||||
it("narrows to rect type with RectAttrs", () => {
|
||||
const obj: CanvasObject = {
|
||||
id: "r1",
|
||||
type: "rect",
|
||||
layerId: "layer-1",
|
||||
attrs: {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 50,
|
||||
fill: "#ff0000",
|
||||
stroke: "#000",
|
||||
strokeWidth: 1,
|
||||
cornerRadius: 5,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
if (obj.type === "rect") {
|
||||
const attrs: RectAttrs = obj.attrs;
|
||||
expect(attrs.x).toBe(10);
|
||||
expect(attrs.y).toBe(20);
|
||||
expect(attrs.cornerRadius).toBe(5);
|
||||
} else {
|
||||
expect.unreachable("should have narrowed to rect");
|
||||
}
|
||||
});
|
||||
|
||||
it("narrows to text type with TextAttrs", () => {
|
||||
const obj: CanvasObject = {
|
||||
id: "t1",
|
||||
type: "text",
|
||||
layerId: "layer-1",
|
||||
attrs: {
|
||||
x: 50,
|
||||
y: 60,
|
||||
text: "Hello",
|
||||
fontFamily: "Arial",
|
||||
fontSize: 16,
|
||||
fontStyle: "normal",
|
||||
fontVariant: "normal",
|
||||
textDecoration: "",
|
||||
align: "left",
|
||||
fill: "#000",
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: 0,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
if (obj.type === "text") {
|
||||
const attrs: TextAttrs = obj.attrs;
|
||||
expect(attrs.text).toBe("Hello");
|
||||
expect(attrs.fontFamily).toBe("Arial");
|
||||
expect(attrs.fontSize).toBe(16);
|
||||
} else {
|
||||
expect.unreachable("should have narrowed to text");
|
||||
}
|
||||
});
|
||||
|
||||
it("narrows to ellipse type with EllipseAttrs", () => {
|
||||
const obj: CanvasObject = {
|
||||
id: "e1",
|
||||
type: "ellipse",
|
||||
layerId: "layer-1",
|
||||
attrs: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
radiusX: 50,
|
||||
radiusY: 30,
|
||||
fill: "#00ff00",
|
||||
stroke: "#000",
|
||||
strokeWidth: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
if (obj.type === "ellipse") {
|
||||
const attrs: EllipseAttrs = obj.attrs;
|
||||
expect(attrs.radiusX).toBe(50);
|
||||
expect(attrs.radiusY).toBe(30);
|
||||
} else {
|
||||
expect.unreachable("should have narrowed to ellipse");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolType", () => {
|
||||
it("includes expected tool variants", () => {
|
||||
const tools: ToolType[] = [
|
||||
"move",
|
||||
"marquee-rect",
|
||||
"marquee-ellipse",
|
||||
"lasso-free",
|
||||
"lasso-poly",
|
||||
"magic-wand",
|
||||
"crop",
|
||||
"eyedropper",
|
||||
"brush",
|
||||
"eraser",
|
||||
"pencil",
|
||||
"clone-stamp",
|
||||
"dodge",
|
||||
"burn",
|
||||
"sponge",
|
||||
"blur-brush",
|
||||
"sharpen-brush",
|
||||
"smudge",
|
||||
"fill",
|
||||
"gradient",
|
||||
"shape-rect",
|
||||
"shape-ellipse",
|
||||
"shape-line",
|
||||
"shape-arrow",
|
||||
"shape-polygon",
|
||||
"shape-star",
|
||||
"text",
|
||||
"hand",
|
||||
"zoom",
|
||||
"transform",
|
||||
];
|
||||
// If this compiles without error, the union accepts all these values
|
||||
expect(tools).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("each variant is a string", () => {
|
||||
const tool: ToolType = "brush";
|
||||
expect(typeof tool).toBe("string");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user