Testing infrastructure, fix truncating of dataURLs (#26)
* feat: implement comprehensive testing infrastructure - Fix image dataURL truncation bug in security.ts with configurable size limits - Add backend integration tests (22 tests) with Vitest for API validation - Add frontend unit tests (11 tests) for JSON serialization - Implement browser-based E2E tests (8 tests) with Playwright - Create Docker setup for repeatable E2E testing environment - Add GitHub Actions CI workflow for automated testing - Update .gitignore for test artifacts and temporary files Testing Infrastructure: - Backend: Vitest + Supertest for API integration tests - Frontend: Vitest + Testing Library for component tests - E2E: Playwright with Chromium for full browser automation - CI/CD: GitHub Actions with parallel test execution Security Improvements: - Make dataURL size limit configurable (default: 10MB) - Enhanced validation for image dataURLs - Block malicious content (javascript:, script tags) All tests pass: 41 total (22 backend + 11 frontend + 8 E2E) * feat(tests): add comprehensive E2E tests for dashboard workflows and image persistence chore(env): update environment variables for consistent API URL usage fix(api): centralize API request helpers for drawing and collection management style(DrawingCard): enhance accessibility with ARIA attributes and data-testid for testing * cleanup/revise documentation * cleanup/revise documentation * Add end-to-end tests for drawing CRUD, export/import, search/sort, and theme toggle functionalities - Implemented E2E tests for drawing creation, editing, and deletion in `drawing-crud.spec.ts`. - Added tests for export and import features, including JSON and SQLite formats in `export-import.spec.ts`. - Created tests for searching and sorting drawings by name and date in `search-and-sort.spec.ts`. - Developed tests for theme toggle functionality to ensure persistence across sessions in `theme-toggle.spec.ts`. * fix: exclude test files from production build to fix Docker build * feat: implement comprehensive testing infrastructure (#19) * bump version 0.1.7 * feat: implement comprehensive testing infrastructure - Fix image dataURL truncation bug in security.ts with configurable size limits - Add backend integration tests (22 tests) with Vitest for API validation - Add frontend unit tests (11 tests) for JSON serialization - Implement browser-based E2E tests (8 tests) with Playwright - Create Docker setup for repeatable E2E testing environment - Add GitHub Actions CI workflow for automated testing - Update .gitignore for test artifacts and temporary files Testing Infrastructure: - Backend: Vitest + Supertest for API integration tests - Frontend: Vitest + Testing Library for component tests - E2E: Playwright with Chromium for full browser automation - CI/CD: GitHub Actions with parallel test execution Security Improvements: - Make dataURL size limit configurable (default: 10MB) - Enhanced validation for image dataURLs - Block malicious content (javascript:, script tags) All tests pass: 41 total (22 backend + 11 frontend + 8 E2E) * feat(tests): add comprehensive E2E tests for dashboard workflows and image persistence chore(env): update environment variables for consistent API URL usage fix(api): centralize API request helpers for drawing and collection management style(DrawingCard): enhance accessibility with ARIA attributes and data-testid for testing * Add end-to-end tests for drawing CRUD, export/import, search/sort, and theme toggle functionalities - Implemented E2E tests for drawing creation, editing, and deletion in `drawing-crud.spec.ts`. - Added tests for export and import features, including JSON and SQLite formats in `export-import.spec.ts`. - Created tests for searching and sorting drawings by name and date in `search-and-sort.spec.ts`. - Developed tests for theme toggle functionality to ensure persistence across sessions in `theme-toggle.spec.ts`. * Update backend/src/__tests__/testUtils.ts --------- Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com> * version bump 0.1.8 * fix(ci): consolidate E2E server startup to prevent shell isolation issues Background processes started with & in separate GitHub Actions run steps can terminate when those steps complete because each step creates a new shell. This caused the backend and frontend servers to die before the E2E tests could run. Fixed by consolidating server startup and test execution into a single shell step with: - Proper PID tracking for cleanup - Health check loops instead of fixed sleep times - All processes run in the same shell session * fix(ci): use absolute database path for E2E tests * fix(backend): use resolved DATABASE_URL path for export/import endpoints --------- Co-authored-by: Adrian Acala <adrianacala017@gmail.com>
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
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
|
||||
*/
|
||||
|
||||
test.describe("Drawing Creation", () => {
|
||||
let createdDrawingIds: string[] = [];
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
// 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 });
|
||||
|
||||
// Click on the drawing name to edit it - it's a button that becomes an input
|
||||
const nameElement = page.getByText(originalName);
|
||||
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 });
|
||||
|
||||
// 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 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 (e) {
|
||||
// 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 (e) {
|
||||
// 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(`http://localhost:8000/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 drawing = await createDrawing(request, { name: `Duplicate_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);
|
||||
|
||||
// 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();
|
||||
|
||||
// Wait for the duplicate to be created
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Clear search to see all drawings
|
||||
await page.getByPlaceholder("Search drawings...").fill("");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Search again to find both
|
||||
await page.getByPlaceholder("Search drawings...").fill("Duplicate_Test");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// There should be two cards now
|
||||
const cards = page.locator("[id^='drawing-card-']");
|
||||
await expect(cards).toHaveCount(2);
|
||||
|
||||
// Get the duplicate ID for cleanup
|
||||
const allDrawings = await listDrawings(request, { search: "Duplicate_Test" });
|
||||
for (const d of allDrawings) {
|
||||
if (!createdDrawingIds.includes(d.id)) {
|
||||
createdDrawingIds.push(d.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user