Files
ExcaliDash/e2e/tests/drawing-crud.spec.ts
T
Zimeng Xiong 1117dc584e resolve e2e
2026-02-07 19:24:00 -08:00

451 lines
15 KiB
TypeScript

import { test, expect } from "@playwright/test";
import {
API_URL,
createDrawing,
deleteDrawing,
getDrawing,
listDrawings,
} from "./helpers/api";
/**
* E2E Tests for Drawing Creation and Editing
*
* Tests the persistent storage feature mentioned in README:
* - Create new drawings
* - Edit drawing names
* - Delete drawings
* - Drawing canvas interactions
* - Auto-save functionality
*/
const revealEditorHeader = async (page: import("@playwright/test").Page) => {
// Editor header auto-hides after a short delay unless pointer is near the top edge.
await page.mouse.move(24, 2);
await page.waitForTimeout(150);
};
test.describe("Drawing Creation", () => {
let createdDrawingIds: string[] = [];
test.afterEach(async ({ request }) => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch {
// Ignore cleanup errors
}
}
createdDrawingIds = [];
});
test("should create a new drawing via UI", async ({ page, request }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Click the New Drawing button
const newDrawingButton = page.getByRole("button", { name: /New Drawing/i });
await newDrawingButton.click();
// Should navigate to editor
await page.waitForURL(/\/editor\//);
// Extract the drawing ID from the URL
const url = page.url();
const match = url.match(/\/editor\/([^/]+)/);
expect(match).toBeTruthy();
const drawingId = match![1];
createdDrawingIds.push(drawingId);
// Verify the editor loaded
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
// Verify drawing was created in the database
const drawing = await getDrawing(request, drawingId);
expect(drawing).toBeDefined();
expect(drawing.name).toBe("Untitled Drawing");
});
test("should open existing drawing in editor", async ({ page, request }) => {
// Create a drawing via API
const drawing = await createDrawing(request, { name: `Open_Test_${Date.now()}` });
createdDrawingIds.push(drawing.id);
await page.goto("/");
await page.waitForLoadState("networkidle");
// Search for the drawing
await page.getByPlaceholder("Search drawings...").fill(drawing.name);
await page.waitForTimeout(500);
// Click on the drawing card
const card = page.locator(`#drawing-card-${drawing.id}`);
await card.click();
// Should navigate to editor
await page.waitForURL(`/editor/${drawing.id}`);
// Verify editor loaded
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
});
test("should display drawing name in editor header", async ({ page, request }) => {
const drawingName = `Header_Test_${Date.now()}`;
const drawing = await createDrawing(request, { name: drawingName });
createdDrawingIds.push(drawing.id);
await page.goto(`/editor/${drawing.id}`);
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
// The drawing name should be visible in the header
await expect(page.getByText(drawingName)).toBeVisible();
});
test("should rename drawing via editor header", async ({ page, request }) => {
const originalName = `Rename_Original_${Date.now()}`;
const newName = `Rename_Updated_${Date.now()}`;
const drawing = await createDrawing(request, { name: originalName });
createdDrawingIds.push(drawing.id);
await page.goto(`/editor/${drawing.id}`);
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
await revealEditorHeader(page);
// Click on the drawing name to edit it - it's a button that becomes an input
const nameElement = page.getByText(originalName);
await expect(nameElement).toBeInViewport();
await nameElement.dblclick();
// Wait for edit mode
await page.waitForTimeout(300);
// Type new name - the input should now be visible
const nameInput = page.locator("input").filter({ hasText: "" }).first();
await nameInput.clear();
await nameInput.fill(newName);
await nameInput.press("Enter");
// Wait for save
await page.waitForTimeout(1000);
// Verify the name was updated via API
const updatedDrawing = await getDrawing(request, drawing.id);
expect(updatedDrawing.name).toBe(newName);
});
test("should navigate back to dashboard from editor", async ({ page, request }) => {
const drawing = await createDrawing(request, { name: `BackNav_Test_${Date.now()}` });
createdDrawingIds.push(drawing.id);
await page.goto(`/editor/${drawing.id}`);
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
await revealEditorHeader(page);
// Find and click the back button (arrow left icon in header)
// The back button is a button element containing an ArrowLeft icon
const backButton = page.locator("header button").first();
await expect(backButton).toBeInViewport();
await backButton.click();
// Should navigate back to dashboard
await page.waitForURL("/");
// Dashboard should be visible
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible();
});
});
test.describe("Drawing Editing", () => {
let createdDrawingIds: string[] = [];
test.afterEach(async ({ request }) => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch {
// Ignore cleanup errors
}
}
createdDrawingIds = [];
});
test("should draw a rectangle on canvas", async ({ page, request }) => {
const drawing = await createDrawing(request, {
name: `Draw_Rect_${Date.now()}`,
elements: [],
});
createdDrawingIds.push(drawing.id);
await page.goto(`/editor/${drawing.id}`);
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
await page.waitForTimeout(1500);
// Get the canvas bounding box
const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found");
console.log(`Canvas bounding box: x=${box.x}, y=${box.y}, width=${box.width}, height=${box.height}`);
// Click on the rectangle tool using the label element
// Find the label that contains the rectangle radio button
const rectangleLabel = page.locator('label:has([data-testid="toolbar-rectangle"])');
await rectangleLabel.click();
await page.waitForTimeout(500);
// Verify the tool was selected
const isRectangleSelectedBefore = await page.locator('[data-testid="toolbar-rectangle"]').isChecked();
console.log("Rectangle tool selected before drawing:", isRectangleSelectedBefore);
// Draw the rectangle by dragging on the canvas - use center of canvas
const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2;
const startX = centerX - 100;
const startY = centerY - 75;
const endX = centerX + 100;
const endY = centerY + 75;
console.log(`Drawing from (${startX}, ${startY}) to (${endX}, ${endY})`);
// First click on the canvas to ensure it has focus
await page.mouse.click(centerX, centerY);
await page.waitForTimeout(200);
// Now draw the rectangle
await page.mouse.move(startX, startY);
await page.waitForTimeout(100);
await page.mouse.down();
await page.waitForTimeout(100);
await page.mouse.move(endX, endY, { steps: 20 });
await page.waitForTimeout(100);
await page.mouse.up();
// Take a screenshot after drawing
await page.screenshot({ path: 'test-results/after-drawing.png' });
// Check if Undo button is now enabled (indicating something was drawn)
const undoButton = page.locator('button[aria-label="Undo"]');
const isUndoDisabled = await undoButton.getAttribute('disabled');
console.log("Undo button disabled:", isUndoDisabled);
// Press Escape to deselect and trigger save
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
// Wait for auto-save (debounced save has a delay of 1000ms)
await page.waitForTimeout(2000);
// Poll for the drawing to have elements (auto-save may take time)
await expect.poll(async () => {
const savedDrawing = await getDrawing(request, drawing.id);
return savedDrawing.elements?.length || 0;
}, { timeout: 15000 }).toBeGreaterThan(0);
});
test("should draw text on canvas", async ({ page, request }) => {
const drawing = await createDrawing(request, {
name: `Draw_Text_${Date.now()}`,
elements: [],
});
createdDrawingIds.push(drawing.id);
await page.goto(`/editor/${drawing.id}`);
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
await page.waitForTimeout(1000);
// Click on the canvas first to focus it
const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found");
// Click to focus the canvas
await page.mouse.click(box.x + 100, box.y + 100);
await page.waitForTimeout(100);
// Select text tool using keyboard shortcut (now that canvas is focused)
await page.keyboard.press("t");
await page.waitForTimeout(200);
// Click to place text
await page.mouse.click(box.x + 300, box.y + 300);
await page.waitForTimeout(200);
// Type some text
await page.keyboard.type("Hello E2E Test");
// Press Escape to finish text editing
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
// Wait for auto-save (debounced save has a delay)
await page.waitForTimeout(3000);
// Poll for the drawing to have elements (auto-save may take time)
await expect.poll(async () => {
const savedDrawing = await getDrawing(request, drawing.id);
return savedDrawing.elements?.length || 0;
}, { timeout: 10000 }).toBeGreaterThan(0);
});
test("should use undo/redo functionality", async ({ page, request }) => {
const drawing = await createDrawing(request, {
name: `Undo_Redo_${Date.now()}`,
elements: [],
});
createdDrawingIds.push(drawing.id);
await page.goto(`/editor/${drawing.id}`);
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
await page.waitForTimeout(1000);
// Draw something on the interactive canvas
const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found");
await page.keyboard.press("r");
await page.waitForTimeout(200);
await page.mouse.move(box.x + 200, box.y + 200);
await page.mouse.down();
await page.mouse.move(box.x + 300, box.y + 300, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(500);
// Undo
await page.keyboard.press("Meta+z");
await page.waitForTimeout(500);
// Redo
await page.keyboard.press("Meta+Shift+z");
await page.waitForTimeout(500);
// The test passes if no errors occur during undo/redo operations
});
});
test.describe("Drawing Deletion", () => {
let createdDrawingIds: string[] = [];
test.afterEach(async ({ request }) => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch {
// Ignore cleanup errors
}
}
createdDrawingIds = [];
});
test("should delete drawing via card menu", async ({ page, request }) => {
const drawing = await createDrawing(request, { name: `Delete_Card_${Date.now()}` });
createdDrawingIds.push(drawing.id);
await page.goto("/");
await page.waitForLoadState("networkidle");
// Search for the drawing
await page.getByPlaceholder("Search drawings...").fill(drawing.name);
await page.waitForTimeout(500);
// Find the card and select it
const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click();
// Click trash button
await page.getByTitle("Move to Trash").click();
// Card should disappear from main view
await expect(card).not.toBeVisible();
// Navigate to trash
await page.getByRole("button", { name: /^Trash$/ }).click();
await page.waitForLoadState("networkidle");
// Drawing should be in trash
await expect(page.locator(`#drawing-card-${drawing.id}`)).toBeVisible();
});
test("should permanently delete drawing from trash", async ({ page, request }) => {
const drawing = await createDrawing(request, {
name: `Perm_Delete_${Date.now()}`,
collectionId: "trash"
});
createdDrawingIds.push(drawing.id);
// Navigate directly to trash
await page.goto("/?view=trash");
await page.getByRole("button", { name: /^Trash$/ }).click();
await page.waitForLoadState("networkidle");
// Select the drawing
const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click();
// Click permanent delete
await page.getByTitle("Delete Permanently").click();
// Confirm deletion
await page.getByRole("button", { name: /Delete \d+ Drawings?/i }).click();
// Card should be gone
await expect(card).not.toBeVisible();
// Verify via API that drawing is deleted
const response = await request.get(`${API_URL}/drawings/${drawing.id}`);
expect(response.status()).toBe(404);
// Remove from cleanup list since it's already deleted
createdDrawingIds = createdDrawingIds.filter(id => id !== drawing.id);
});
test("should duplicate drawing", async ({ page, request }) => {
const baseName = `Duplicate_Test_${Date.now()}`;
const drawing = await createDrawing(request, { name: baseName });
createdDrawingIds.push(drawing.id);
await page.goto("/");
await page.waitForLoadState("networkidle");
// Search for the drawing
await page.getByPlaceholder("Search drawings...").fill(baseName);
await page.waitForTimeout(500);
// Select the drawing
const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click();
// Click duplicate button
await page.getByTitle("Duplicate Selected").click();
await expect.poll(async () => {
const allDrawings = await listDrawings(request, { search: baseName });
return allDrawings.length;
}, { timeout: 10000 }).toBe(2);
await page.getByPlaceholder("Search drawings...").fill(baseName);
await page.waitForTimeout(700);
await expect(page.locator("[id^='drawing-card-']")).toHaveCount(2);
// Get the duplicate ID for cleanup
const allDrawings = await listDrawings(request, { search: baseName });
for (const d of allDrawings) {
if (!createdDrawingIds.includes(d.id)) {
createdDrawingIds.push(d.id);
}
}
});
});