merge: resolve conflict with main branch in README.md

This commit is contained in:
SnapOtter
2026-05-08 17:32:16 +08:00
150 changed files with 32270 additions and 101 deletions
+96
View File
@@ -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();
});
});
+49
View File
@@ -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");
});
});
+75
View File
@@ -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);
});
});
+143
View File
@@ -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");
});
});
+117
View File
@@ -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();
});
});
+53
View File
@@ -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);
}
+156
View File
@@ -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);
});
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1735
View File
File diff suppressed because one or more lines are too long
+5146
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -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({
+96
View File
@@ -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,
},
];
// ---------------------------------------------------------------------------
+267
View File
@@ -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);
}
});
});
+31 -4
View File
@@ -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 () => {
+55
View File
@@ -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);
});
}
});
});
+174
View File
@@ -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
+168
View File
@@ -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");
});
});