0.2.1 Release (#32)
* feat(security): implement CSRF protection * chore: clean up CSRF implementation - Remove unused generateCsrfToken export from security.ts - Remove redundant /csrf-token path check (GET already exempt) - Restore defineConfig wrapper in vitest.config.ts for type safety * add K8S note in README, fix broken e2e * feat/upload-bar (#30) * feat/upload-bar: add a upload bar when user upload file, indicate the upload process * feat/save-loading-status: add save status when click back button from editor * fix: address PR review issues in upload and save features - Replace deprecated substr() with substring() in UploadContext - Fix broken error handling that checked stale task status - Fix missing useEffect dependency in UploadStatus - Fix CSS class conflict in progress bar styling - Add error recovery for save state in Editor (reset on failure) - Use .finally() instead of .then() to ensure refresh on upload failure - Fix inconsistent indentation in UploadContext * fix e2e tests --------- Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com> * chore: pre-release v0.2.1-dev * Update backend/src/security.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix filename/math random UUID generation --------- Co-authored-by: AdrianAcala <adrianacala017@gmail.com> Co-authored-by: adamant368 <60790941+Yiheng-Liu@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Playwright E2E Test Runner
|
||||
FROM mcr.microsoft.com/playwright:v1.52.0-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -17,14 +17,18 @@ services:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- DATABASE_URL=file:./prisma/e2e-test.db
|
||||
# Use an absolute sqlite path so Prisma CLI + the running app always point
|
||||
# at the same DB file (avoids schema being applied to a different relative path).
|
||||
- DATABASE_URL=file:/app/prisma/e2e-test.db
|
||||
- PORT=8000
|
||||
- NODE_ENV=test
|
||||
- FRONTEND_URL=http://frontend:80,http://localhost:5173
|
||||
# Include both with and without :80 because browsers omit default ports in Origin.
|
||||
- FRONTEND_URL=http://frontend,http://frontend:80,http://localhost:5173
|
||||
ports:
|
||||
- "8000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/health"]
|
||||
# Use IPv4 loopback explicitly to avoid IPv6 localhost resolution issues.
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8000/health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -35,17 +39,18 @@ services:
|
||||
# Frontend web server
|
||||
frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=http://backend:8000
|
||||
# Use the repo root as build context because `frontend/Dockerfile` expects
|
||||
# `frontend/...` paths (same as production `docker-compose.yml`).
|
||||
context: ..
|
||||
dockerfile: frontend/Dockerfile
|
||||
ports:
|
||||
- "5173:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80"]
|
||||
# Use IPv4 loopback explicitly to avoid IPv6 localhost resolution issues.
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
+28
-18
@@ -17,49 +17,56 @@ const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
|
||||
|
||||
// Run tests in parallel
|
||||
fullyParallel: true,
|
||||
|
||||
|
||||
// Fail the build on test.only() in CI
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
|
||||
// Retry on CI only
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
|
||||
// Limit parallel workers in CI
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
||||
|
||||
// Reporter configuration
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { outputFolder: "playwright-report" }],
|
||||
[
|
||||
"html",
|
||||
{
|
||||
// Useful when a previous Docker run produced root-owned artifacts.
|
||||
// Allows local runs to redirect output without editing the config.
|
||||
outputFolder: process.env.PLAYWRIGHT_REPORT_DIR || "playwright-report",
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
// Output folder for test artifacts
|
||||
outputDir: "test-results",
|
||||
|
||||
outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR || "test-results",
|
||||
|
||||
// Global timeout for each test
|
||||
timeout: 60000,
|
||||
|
||||
|
||||
// Expect timeout
|
||||
expect: {
|
||||
timeout: 10000,
|
||||
},
|
||||
|
||||
|
||||
use: {
|
||||
// Base URL for page.goto()
|
||||
baseURL: FRONTEND_URL,
|
||||
|
||||
|
||||
// Collect trace on first retry
|
||||
trace: "on-first-retry",
|
||||
|
||||
|
||||
// Screenshot on failure
|
||||
screenshot: "only-on-failure",
|
||||
|
||||
|
||||
// Video on failure
|
||||
video: "on-first-retry",
|
||||
|
||||
|
||||
// Headed mode based on env var
|
||||
headless: process.env.HEADED !== "true",
|
||||
},
|
||||
@@ -67,7 +74,7 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
// Viewport for consistent screenshots
|
||||
viewport: { width: 1280, height: 720 },
|
||||
@@ -76,7 +83,7 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
// Run local dev servers before tests (skip if NO_SERVER or CI)
|
||||
webServer: (process.env.CI || process.env.NO_SERVER) ? undefined : [
|
||||
webServer: (process.env.CI || process.env.NO_SERVER === "true") ? undefined : [
|
||||
{
|
||||
command: "cd ../backend && npm run dev",
|
||||
url: `${BACKEND_URL}/health`,
|
||||
@@ -85,8 +92,11 @@ export default defineConfig({
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
DATABASE_URL: "file:./prisma/dev.db",
|
||||
// Prisma resolves relative SQLite paths from the schema directory (backend/prisma).
|
||||
// Using `file:./dev.db` avoids accidentally creating `prisma/prisma/dev.db`.
|
||||
DATABASE_URL: "file:./dev.db",
|
||||
FRONTEND_URL,
|
||||
CSRF_MAX_REQUESTS: "1000",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { test, expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
API_URL,
|
||||
createDrawing,
|
||||
deleteDrawing,
|
||||
getDrawing,
|
||||
@@ -22,7 +21,7 @@ test.describe("Real-time Collaboration", () => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -63,7 +62,7 @@ test.describe("Real-time Collaboration", () => {
|
||||
// At least one page should show the other user
|
||||
const hasCollaborator1 = await collaboratorIndicator1.count();
|
||||
const hasCollaborator2 = await collaboratorIndicator2.count();
|
||||
|
||||
|
||||
// Socket.io presence should eventually show users
|
||||
// This test validates the socket connection works
|
||||
expect(hasCollaborator1 + hasCollaborator2).toBeGreaterThanOrEqual(0);
|
||||
@@ -75,7 +74,7 @@ test.describe("Real-time Collaboration", () => {
|
||||
|
||||
test("should sync drawing changes between two users", async ({ browser, request }) => {
|
||||
// Create a test drawing
|
||||
const drawing = await createDrawing(request, {
|
||||
const drawing = await createDrawing(request, {
|
||||
name: `Collab_Sync_${Date.now()}`,
|
||||
elements: [],
|
||||
});
|
||||
@@ -121,10 +120,10 @@ test.describe("Real-time Collaboration", () => {
|
||||
|
||||
// Verify the drawing was saved (via API)
|
||||
const updatedDrawing = await getDrawing(request, drawing.id);
|
||||
|
||||
|
||||
// The drawing should have elements now
|
||||
const elements = updatedDrawing.elements || [];
|
||||
|
||||
|
||||
// Element sync happens via socket and periodic save
|
||||
// The test validates the drawing flow works end-to-end
|
||||
expect(elements).toBeDefined();
|
||||
@@ -136,7 +135,7 @@ test.describe("Real-time Collaboration", () => {
|
||||
|
||||
test("should persist drawing changes across page reload", async ({ page, request }) => {
|
||||
// Create a test drawing
|
||||
const drawing = await createDrawing(request, {
|
||||
const drawing = await createDrawing(request, {
|
||||
name: `Collab_Persist_${Date.now()}`,
|
||||
elements: [],
|
||||
});
|
||||
@@ -149,7 +148,7 @@ test.describe("Real-time Collaboration", () => {
|
||||
|
||||
// Draw something - use the interactive canvas layer
|
||||
const canvas = page.locator("canvas.excalidraw__canvas.interactive");
|
||||
|
||||
|
||||
// Select rectangle tool
|
||||
await page.keyboard.press("r");
|
||||
await page.waitForTimeout(200);
|
||||
@@ -157,7 +156,7 @@ test.describe("Real-time Collaboration", () => {
|
||||
// Draw a rectangle - click on the interactive canvas
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("Canvas not found");
|
||||
|
||||
|
||||
await page.mouse.move(box.x + 150, box.y + 150);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 350, box.y + 250, { steps: 5 });
|
||||
@@ -205,7 +204,7 @@ test.describe("Real-time Collaboration", () => {
|
||||
const canvas1 = page1.locator("canvas.excalidraw__canvas.interactive");
|
||||
const box = await canvas1.boundingBox();
|
||||
if (!box) throw new Error("Canvas not found");
|
||||
|
||||
|
||||
await page1.mouse.move(box.x + 300, box.y + 300);
|
||||
await page1.waitForTimeout(500);
|
||||
await page1.mouse.move(box.x + 400, box.y + 400);
|
||||
@@ -214,7 +213,7 @@ test.describe("Real-time Collaboration", () => {
|
||||
// The cursor position should be broadcasted to page2
|
||||
// Excalidraw shows collaborator cursors with names
|
||||
// This test validates the socket connection for cursor sync
|
||||
|
||||
|
||||
// Wait for potential cursor updates
|
||||
await page2.waitForTimeout(1000);
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ test.describe("Dashboard Workflows", () => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore cleanup failures to keep tests resilient
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ test.describe("Dashboard Workflows", () => {
|
||||
for (const id of createdCollectionIds) {
|
||||
try {
|
||||
await deleteCollection(request, id);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore cleanup failures to keep tests resilient
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { test, expect } from "@playwright/test";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import {
|
||||
API_URL,
|
||||
createDrawing,
|
||||
deleteDrawing,
|
||||
listDrawings,
|
||||
@@ -27,7 +26,7 @@ test.describe("Drag and Drop - Collections", () => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -36,7 +35,7 @@ test.describe("Drag and Drop - Collections", () => {
|
||||
for (const id of createdCollectionIds) {
|
||||
try {
|
||||
await deleteCollection(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -61,7 +60,7 @@ test.describe("Drag and Drop - Collections", () => {
|
||||
|
||||
// Hover to reveal the collection picker
|
||||
await card.hover();
|
||||
|
||||
|
||||
// Click the collection picker button on the card
|
||||
const collectionPicker = card.locator(`[data-testid="collection-picker-${drawing.id}"]`);
|
||||
await collectionPicker.click();
|
||||
@@ -76,7 +75,7 @@ test.describe("Drag and Drop - Collections", () => {
|
||||
// Navigate to the collection and verify drawing is there
|
||||
await page.getByRole("navigation").getByRole("button", { name: collection.name }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
|
||||
await expect(card).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -85,9 +84,9 @@ test.describe("Drag and Drop - Collections", () => {
|
||||
const collection = await createCollection(request, `UnorgTest_Collection_${Date.now()}`);
|
||||
createdCollectionIds.push(collection.id);
|
||||
|
||||
const drawing = await createDrawing(request, {
|
||||
const drawing = await createDrawing(request, {
|
||||
name: `UnorgTest_Drawing_${Date.now()}`,
|
||||
collectionId: collection.id
|
||||
collectionId: collection.id
|
||||
});
|
||||
createdDrawingIds.push(drawing.id);
|
||||
|
||||
@@ -119,7 +118,7 @@ test.describe("Drag and Drop - Collections", () => {
|
||||
// Navigate to Unorganized and verify drawing is there
|
||||
await page.getByRole("navigation").getByRole("button", { name: "Unorganized" }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
|
||||
await expect(page.locator(`#drawing-card-${drawing.id}`)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -146,7 +145,7 @@ test.describe("Drag and Drop - Collections", () => {
|
||||
// Select both drawings
|
||||
const card1 = page.locator(`#drawing-card-${drawing1.id}`);
|
||||
const card2 = page.locator(`#drawing-card-${drawing2.id}`);
|
||||
|
||||
|
||||
await card1.hover();
|
||||
const toggle1 = card1.locator(`[data-testid="select-drawing-${drawing1.id}"]`);
|
||||
await toggle1.click();
|
||||
@@ -186,7 +185,7 @@ test.describe("Drag and Drop - File Import", () => {
|
||||
for (const drawing of drawings) {
|
||||
try {
|
||||
await deleteDrawing(request, drawing.id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -194,7 +193,7 @@ test.describe("Drag and Drop - File Import", () => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -205,7 +204,7 @@ test.describe("Drag and Drop - File Import", () => {
|
||||
// Note: Simulating drag events with files is unreliable in Playwright
|
||||
// because the DataTransfer API has security restrictions.
|
||||
// This test verifies the drop zone UI exists and can be triggered.
|
||||
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
@@ -218,13 +217,13 @@ test.describe("Drag and Drop - File Import", () => {
|
||||
try {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File(['test'], 'test.excalidraw', { type: 'application/json' }));
|
||||
|
||||
|
||||
const event = new DragEvent('dragenter', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer: dt,
|
||||
});
|
||||
|
||||
|
||||
// Find the main content area and dispatch the event
|
||||
const main = document.querySelector('main');
|
||||
if (main) {
|
||||
@@ -242,7 +241,7 @@ test.describe("Drag and Drop - File Import", () => {
|
||||
// Check that the drop zone overlay is shown
|
||||
const dropZone = page.getByText("Drop files to import");
|
||||
const isVisible = await dropZone.isVisible().catch(() => false);
|
||||
|
||||
|
||||
if (isVisible) {
|
||||
await expect(dropZone).toBeVisible();
|
||||
} else {
|
||||
@@ -255,7 +254,7 @@ test.describe("Drag and Drop - File Import", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should import excalidraw file via file input", async ({ page, request }, testInfo) => {
|
||||
test("should import excalidraw file via file input", async ({ page }, testInfo) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
@@ -273,11 +272,8 @@ test.describe("Drag and Drop - File Import", () => {
|
||||
const fileInput = page.locator("#dashboard-import");
|
||||
await fileInput.setInputFiles(fixturePath);
|
||||
|
||||
// Wait for import success modal
|
||||
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Dismiss the modal
|
||||
await page.getByRole("button", { name: "OK" }).click();
|
||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Search for the imported drawing (it uses the filename as name)
|
||||
await page.getByPlaceholder("Search drawings...").fill("small-image");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
API_URL,
|
||||
createDrawing,
|
||||
deleteDrawing,
|
||||
getDrawing,
|
||||
@@ -24,7 +25,7 @@ test.describe("Drawing Creation", () => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -96,7 +97,7 @@ test.describe("Drawing Creation", () => {
|
||||
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);
|
||||
|
||||
@@ -150,7 +151,7 @@ test.describe("Drawing Editing", () => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -158,7 +159,7 @@ test.describe("Drawing Editing", () => {
|
||||
});
|
||||
|
||||
test("should draw a rectangle on canvas", async ({ page, request }) => {
|
||||
const drawing = await createDrawing(request, {
|
||||
const drawing = await createDrawing(request, {
|
||||
name: `Draw_Rect_${Date.now()}`,
|
||||
elements: [],
|
||||
});
|
||||
@@ -172,19 +173,19 @@ test.describe("Drawing Editing", () => {
|
||||
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;
|
||||
@@ -192,13 +193,13 @@ test.describe("Drawing Editing", () => {
|
||||
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);
|
||||
@@ -207,10 +208,10 @@ test.describe("Drawing Editing", () => {
|
||||
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');
|
||||
@@ -231,7 +232,7 @@ test.describe("Drawing Editing", () => {
|
||||
});
|
||||
|
||||
test("should draw text on canvas", async ({ page, request }) => {
|
||||
const drawing = await createDrawing(request, {
|
||||
const drawing = await createDrawing(request, {
|
||||
name: `Draw_Text_${Date.now()}`,
|
||||
elements: [],
|
||||
});
|
||||
@@ -245,11 +246,11 @@ test.describe("Drawing Editing", () => {
|
||||
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);
|
||||
@@ -260,7 +261,7 @@ test.describe("Drawing Editing", () => {
|
||||
|
||||
// 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);
|
||||
@@ -276,7 +277,7 @@ test.describe("Drawing Editing", () => {
|
||||
});
|
||||
|
||||
test("should use undo/redo functionality", async ({ page, request }) => {
|
||||
const drawing = await createDrawing(request, {
|
||||
const drawing = await createDrawing(request, {
|
||||
name: `Undo_Redo_${Date.now()}`,
|
||||
elements: [],
|
||||
});
|
||||
@@ -290,10 +291,10 @@ test.describe("Drawing Editing", () => {
|
||||
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 });
|
||||
@@ -320,7 +321,7 @@ test.describe("Drawing Deletion", () => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -341,7 +342,7 @@ test.describe("Drawing Deletion", () => {
|
||||
// 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();
|
||||
|
||||
@@ -360,9 +361,9 @@ test.describe("Drawing Deletion", () => {
|
||||
});
|
||||
|
||||
test("should permanently delete drawing from trash", async ({ page, request }) => {
|
||||
const drawing = await createDrawing(request, {
|
||||
const drawing = await createDrawing(request, {
|
||||
name: `Perm_Delete_${Date.now()}`,
|
||||
collectionId: "trash"
|
||||
collectionId: "trash"
|
||||
});
|
||||
createdDrawingIds.push(drawing.id);
|
||||
|
||||
@@ -374,7 +375,7 @@ test.describe("Drawing Deletion", () => {
|
||||
// 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();
|
||||
|
||||
@@ -388,7 +389,7 @@ test.describe("Drawing Deletion", () => {
|
||||
await expect(card).not.toBeVisible();
|
||||
|
||||
// Verify via API that drawing is deleted
|
||||
const response = await request.get(`http://localhost:8000/drawings/${drawing.id}`);
|
||||
const response = await request.get(`${API_URL}/drawings/${drawing.id}`);
|
||||
expect(response.status()).toBe(404);
|
||||
|
||||
// Remove from cleanup list since it's already deleted
|
||||
@@ -409,7 +410,7 @@ test.describe("Drawing Deletion", () => {
|
||||
// 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();
|
||||
|
||||
@@ -422,7 +423,7 @@ test.describe("Drawing Deletion", () => {
|
||||
// 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);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {
|
||||
API_URL,
|
||||
createDrawing,
|
||||
deleteDrawing,
|
||||
getCsrfHeaders,
|
||||
listDrawings,
|
||||
createCollection,
|
||||
deleteCollection,
|
||||
} from "./helpers/api";
|
||||
|
||||
@@ -29,7 +27,7 @@ test.describe("Export Functionality", () => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -38,7 +36,7 @@ test.describe("Export Functionality", () => {
|
||||
for (const id of createdCollectionIds) {
|
||||
try {
|
||||
await deleteCollection(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -85,11 +83,11 @@ test.describe("Export Functionality", () => {
|
||||
// Test JSON/ZIP export endpoint - it returns a ZIP file with .excalidraw files
|
||||
const zipResponse = await request.get(`${API_URL}/export/json`);
|
||||
expect(zipResponse.ok()).toBe(true);
|
||||
|
||||
|
||||
// Check it's a ZIP file
|
||||
const contentType = zipResponse.headers()["content-type"];
|
||||
expect(contentType).toMatch(/application\/zip/);
|
||||
|
||||
|
||||
// Check content-disposition header
|
||||
const contentDisposition = zipResponse.headers()["content-disposition"];
|
||||
expect(contentDisposition).toContain("attachment");
|
||||
@@ -103,11 +101,11 @@ test.describe("Export Functionality", () => {
|
||||
// Test SQLite export endpoint
|
||||
const sqliteResponse = await request.get(`${API_URL}/export`);
|
||||
expect(sqliteResponse.ok()).toBe(true);
|
||||
|
||||
|
||||
// Check content-type header indicates a file download
|
||||
const contentType = sqliteResponse.headers()["content-type"];
|
||||
expect(contentType).toMatch(/application\/octet-stream|application\/x-sqlite3/);
|
||||
|
||||
|
||||
// Check content-disposition header
|
||||
const contentDisposition = sqliteResponse.headers()["content-disposition"];
|
||||
expect(contentDisposition).toContain("attachment");
|
||||
@@ -121,7 +119,7 @@ test.describe("Export Functionality", () => {
|
||||
// Test .db export endpoint
|
||||
const dbResponse = await request.get(`${API_URL}/export?format=db`);
|
||||
expect(dbResponse.ok()).toBe(true);
|
||||
|
||||
|
||||
const contentDisposition = dbResponse.headers()["content-disposition"];
|
||||
expect(contentDisposition).toContain("attachment");
|
||||
expect(contentDisposition).toMatch(/\.db/);
|
||||
@@ -137,7 +135,7 @@ test.describe.serial("Import Functionality", () => {
|
||||
for (const drawing of testDrawings) {
|
||||
try {
|
||||
await deleteDrawing(request, drawing.id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -145,7 +143,7 @@ test.describe.serial("Import Functionality", () => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -161,7 +159,7 @@ test.describe.serial("Import Functionality", () => {
|
||||
await expect(importButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("should import .excalidraw file from Dashboard", async ({ page, request }) => {
|
||||
test("should import .excalidraw file from Dashboard", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
@@ -206,15 +204,14 @@ test.describe.serial("Import Functionality", () => {
|
||||
});
|
||||
|
||||
// Write temp file
|
||||
const tempDir = "/tmp";
|
||||
const tempFile = `${tempDir}/Import_Test_${Date.now()}.excalidraw`;
|
||||
|
||||
// tempFile was here
|
||||
|
||||
// Use page.evaluate to check if we can proceed
|
||||
// Actually, Playwright has setInputFiles which can handle this
|
||||
|
||||
// Find the import file input
|
||||
const fileInput = page.locator("#dashboard-import");
|
||||
|
||||
|
||||
// Create a buffer from the fixture content
|
||||
await fileInput.setInputFiles({
|
||||
name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`,
|
||||
@@ -222,9 +219,8 @@ test.describe.serial("Import Functionality", () => {
|
||||
buffer: Buffer.from(fixtureContent),
|
||||
});
|
||||
|
||||
// Wait for success modal
|
||||
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "OK" }).click();
|
||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Reload to ensure dashboard state reflects the newly imported drawing
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
@@ -237,13 +233,13 @@ test.describe.serial("Import Functionality", () => {
|
||||
await expect(importedCards.first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("should import JSON drawing file from Dashboard", async ({ page, request }) => {
|
||||
test("should import JSON drawing file from Dashboard", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const timestamp = Date.now();
|
||||
const testName = `Import_JSONTest_${timestamp}`;
|
||||
|
||||
|
||||
// Create a valid excalidraw JSON file with required fields
|
||||
const jsonContent = JSON.stringify({
|
||||
type: "excalidraw",
|
||||
@@ -283,31 +279,22 @@ test.describe.serial("Import Functionality", () => {
|
||||
});
|
||||
|
||||
const fileInput = page.locator("#dashboard-import");
|
||||
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: `${testName}.json`,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(jsonContent),
|
||||
});
|
||||
|
||||
// Wait for import result - could be success or failure
|
||||
const successModal = page.getByText("Import Successful");
|
||||
const failModal = page.getByText("Import Failed");
|
||||
|
||||
await expect(successModal.or(failModal)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// If we got a failure, check the error
|
||||
if (await failModal.isVisible()) {
|
||||
// Get the error message
|
||||
const errorText = await page.locator(".modal, [role='dialog']").textContent();
|
||||
console.log("Import failed with:", errorText);
|
||||
// Still click OK to dismiss
|
||||
await page.getByRole("button", { name: "OK" }).click();
|
||||
// Skip the rest of the test since import failed
|
||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Check if upload failed (shows "Failed" text in the upload status)
|
||||
const failedIndicator = page.getByText("Failed");
|
||||
if (await failedIndicator.isVisible()) {
|
||||
console.log("Import failed - skipping rest of test");
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "OK" }).click();
|
||||
|
||||
// Reload to force a fresh fetch of drawings after import
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
@@ -331,16 +318,17 @@ test.describe.serial("Import Functionality", () => {
|
||||
const invalidContent = "this is not valid JSON or excalidraw format {}{}";
|
||||
|
||||
const fileInput = page.locator("#dashboard-import");
|
||||
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: `Import_Invalid_${Date.now()}.excalidraw`,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(invalidContent),
|
||||
});
|
||||
|
||||
// Should show error modal
|
||||
await expect(page.getByText("Import Failed")).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "OK" }).click();
|
||||
// Wait for upload to complete and check for failure indicator
|
||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
||||
// Should show "Failed" status in the upload status component
|
||||
await expect(page.getByText("Failed")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should import multiple drawings at once", async ({ page }) => {
|
||||
@@ -377,8 +365,8 @@ test.describe.serial("Import Functionality", () => {
|
||||
const fileInput = page.locator("#dashboard-import");
|
||||
await fileInput.setInputFiles(files);
|
||||
|
||||
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "OK" }).click();
|
||||
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
|
||||
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify both were imported by searching for the unique prefix
|
||||
await page.getByPlaceholder("Search drawings...").fill(searchPrefix);
|
||||
@@ -394,6 +382,7 @@ test.describe("Database Import Verification", () => {
|
||||
// Test that the verification endpoint responds
|
||||
// We don't actually import a database as that would affect the test environment
|
||||
const response = await request.post(`${API_URL}/import/sqlite/verify`, {
|
||||
headers: await getCsrfHeaders(request),
|
||||
// Send empty form data to test endpoint exists
|
||||
multipart: {
|
||||
db: {
|
||||
@@ -403,7 +392,7 @@ test.describe("Database Import Verification", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Should get an error response since the file is empty/invalid
|
||||
// But the endpoint should exist
|
||||
expect([400, 500]).toContain(response.status());
|
||||
|
||||
+141
-6
@@ -5,6 +5,91 @@ const DEFAULT_BACKEND_PORT = 8000;
|
||||
|
||||
export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`;
|
||||
|
||||
type CsrfTokenResponse = {
|
||||
token: string;
|
||||
header?: string;
|
||||
};
|
||||
|
||||
type CsrfInfo = {
|
||||
token: string;
|
||||
headerName: string;
|
||||
};
|
||||
|
||||
// Cache CSRF tokens per Playwright request context so parallel tests don't race.
|
||||
const csrfInfoByRequest = new WeakMap<APIRequestContext, CsrfInfo>();
|
||||
const csrfFetchByRequest = new WeakMap<APIRequestContext, Promise<CsrfInfo>>();
|
||||
|
||||
const fetchCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
|
||||
const response = await request.get(`${API_URL}/csrf-token`);
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
`Failed to fetch CSRF token: ${response.status()} ${text || "(empty response)"}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as CsrfTokenResponse;
|
||||
if (!data || typeof data.token !== "string" || data.token.trim().length === 0) {
|
||||
throw new Error("Failed to fetch CSRF token: missing token in response");
|
||||
}
|
||||
|
||||
const headerName =
|
||||
typeof data.header === "string" && data.header.trim().length > 0
|
||||
? data.header
|
||||
: "x-csrf-token";
|
||||
|
||||
return { token: data.token, headerName };
|
||||
};
|
||||
|
||||
const getCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
|
||||
const cached = csrfInfoByRequest.get(request);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlight = csrfFetchByRequest.get(request);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const promise = fetchCsrfInfo(request)
|
||||
.then((info) => {
|
||||
csrfInfoByRequest.set(request, info);
|
||||
return info;
|
||||
})
|
||||
.finally(() => {
|
||||
csrfFetchByRequest.delete(request);
|
||||
});
|
||||
|
||||
csrfFetchByRequest.set(request, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
const refreshCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
|
||||
const promise = fetchCsrfInfo(request)
|
||||
.then((info) => {
|
||||
csrfInfoByRequest.set(request, info);
|
||||
return info;
|
||||
})
|
||||
.finally(() => {
|
||||
csrfFetchByRequest.delete(request);
|
||||
});
|
||||
|
||||
csrfFetchByRequest.set(request, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
export async function getCsrfHeaders(
|
||||
request: APIRequestContext
|
||||
): Promise<Record<string, string>> {
|
||||
const info = await getCsrfInfo(request);
|
||||
return { [info.headerName]: info.token };
|
||||
}
|
||||
|
||||
const withCsrfHeaders = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<Record<string, string>> => ({
|
||||
...headers,
|
||||
...(await getCsrfHeaders(request)),
|
||||
});
|
||||
|
||||
export interface DrawingRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -53,10 +138,26 @@ export async function createDrawing(
|
||||
overrides: CreateDrawingOptions = {}
|
||||
): Promise<DrawingRecord> {
|
||||
const payload = { ...defaultDrawingPayload(), ...overrides };
|
||||
const response = await request.post(`${API_URL}/drawings`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
|
||||
|
||||
let response = await request.post(`${API_URL}/drawings`, {
|
||||
headers,
|
||||
data: payload,
|
||||
});
|
||||
|
||||
// Retry once with a fresh token in case it expired or the cache was primed under
|
||||
// a different clientId (rare, but can happen under parallelism / CI proxies).
|
||||
if (!response.ok() && response.status() === 403) {
|
||||
await refreshCsrfInfo(request);
|
||||
const retryHeaders = await withCsrfHeaders(request, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response = await request.post(`${API_URL}/drawings`, {
|
||||
headers: retryHeaders,
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to create drawing: ${response.status()} ${text}`);
|
||||
@@ -77,7 +178,17 @@ export async function deleteDrawing(
|
||||
request: APIRequestContext,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const response = await request.delete(`${API_URL}/drawings/${id}`);
|
||||
const headers = await withCsrfHeaders(request);
|
||||
let response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
|
||||
|
||||
if (!response.ok() && response.status() === 403) {
|
||||
await refreshCsrfInfo(request);
|
||||
const retryHeaders = await withCsrfHeaders(request);
|
||||
response = await request.delete(`${API_URL}/drawings/${id}`, {
|
||||
headers: retryHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
// Ignore not found to keep cleanup idempotent
|
||||
if (response.status() !== 404) {
|
||||
@@ -113,10 +224,24 @@ export async function createCollection(
|
||||
request: APIRequestContext,
|
||||
name: string
|
||||
): Promise<CollectionRecord> {
|
||||
const response = await request.post(`${API_URL}/collections`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
|
||||
|
||||
let response = await request.post(`${API_URL}/collections`, {
|
||||
headers,
|
||||
data: { name },
|
||||
});
|
||||
|
||||
if (!response.ok() && response.status() === 403) {
|
||||
await refreshCsrfInfo(request);
|
||||
const retryHeaders = await withCsrfHeaders(request, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response = await request.post(`${API_URL}/collections`, {
|
||||
headers: retryHeaders,
|
||||
data: { name },
|
||||
});
|
||||
}
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
return (await response.json()) as CollectionRecord;
|
||||
}
|
||||
@@ -133,7 +258,17 @@ export async function deleteCollection(
|
||||
request: APIRequestContext,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const response = await request.delete(`${API_URL}/collections/${id}`);
|
||||
const headers = await withCsrfHeaders(request);
|
||||
let response = await request.delete(`${API_URL}/collections/${id}`, { headers });
|
||||
|
||||
if (!response.ok() && response.status() === 403) {
|
||||
await refreshCsrfInfo(request);
|
||||
const retryHeaders = await withCsrfHeaders(request);
|
||||
response = await request.delete(`${API_URL}/collections/${id}`, {
|
||||
headers: retryHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
if (response.status() !== 404) {
|
||||
const text = await response.text();
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { API_URL, createDrawing, deleteDrawing, getDrawing } from "./helpers/api";
|
||||
import {
|
||||
API_URL,
|
||||
createDrawing,
|
||||
deleteDrawing,
|
||||
getCsrfHeaders,
|
||||
getDrawing,
|
||||
} from "./helpers/api";
|
||||
|
||||
/**
|
||||
* E2E Browser Tests for Image Persistence - Issue #17 Regression
|
||||
@@ -28,13 +34,13 @@ function generateLargeImageDataUrl(sizeInBytes: number = 50000): string {
|
||||
|
||||
test.describe("Image Persistence - Browser E2E Tests", () => {
|
||||
let testDrawingIds: string[] = [];
|
||||
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// Clean up any drawings created during tests
|
||||
for (const id of testDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -43,23 +49,23 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
|
||||
|
||||
test("should navigate to dashboard and see drawing list", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
|
||||
// Wait for the page to load
|
||||
await expect(page).toHaveTitle(/ExcaliDash/i);
|
||||
|
||||
|
||||
// The dashboard should show some UI elements
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should create a new drawing via UI", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
|
||||
// Look for a "New Drawing" or similar button
|
||||
const newDrawingBtn = page.getByRole("button", { name: /new|create/i }).first();
|
||||
|
||||
|
||||
if (await newDrawingBtn.isVisible()) {
|
||||
await newDrawingBtn.click();
|
||||
|
||||
|
||||
// Should navigate to editor or show a modal
|
||||
await page.waitForURL(/\/(editor|drawing)/i, { timeout: 5000 }).catch(() => {
|
||||
// May stay on same page with modal
|
||||
@@ -71,7 +77,7 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
|
||||
// This is the core regression test for issue #17
|
||||
const largeDataUrl = generateLargeImageDataUrl(50000);
|
||||
expect(largeDataUrl.length).toBeGreaterThan(10000);
|
||||
|
||||
|
||||
const files = {
|
||||
"test-image-1": {
|
||||
id: "test-image-1",
|
||||
@@ -80,23 +86,23 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
|
||||
created: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Create drawing with large image
|
||||
const createdDrawing = await createDrawing(request, {
|
||||
name: "E2E Test - Large Image",
|
||||
files,
|
||||
});
|
||||
testDrawingIds.push(createdDrawing.id);
|
||||
|
||||
|
||||
// Retrieve the drawing
|
||||
const drawing = await getDrawing(request, createdDrawing.id);
|
||||
const savedFiles = drawing.files || {}; // Already parsed by API
|
||||
|
||||
|
||||
// Verify the image data was preserved
|
||||
expect(savedFiles["test-image-1"]).toBeDefined();
|
||||
expect(savedFiles["test-image-1"].dataURL).toBe(largeDataUrl);
|
||||
expect(savedFiles["test-image-1"].dataURL.length).toBe(largeDataUrl.length);
|
||||
|
||||
|
||||
console.log("✓ Large image data preserved correctly through save/reload cycle");
|
||||
});
|
||||
|
||||
@@ -106,36 +112,36 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
|
||||
name: "E2E Test - Editor View",
|
||||
});
|
||||
testDrawingIds.push(createdDrawing.id);
|
||||
|
||||
|
||||
// Navigate to the editor
|
||||
await page.goto(`/editor/${createdDrawing.id}`);
|
||||
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
|
||||
// The editor should be visible (Excalidraw canvas)
|
||||
// Look for the Excalidraw container or canvas
|
||||
const editorContainer = page.locator("[class*='excalidraw'], canvas").first();
|
||||
await expect(editorContainer).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("should import .excalidraw file with embedded image", async ({ page, request }) => {
|
||||
test("should import .excalidraw file with embedded image", async ({ request }) => {
|
||||
// Load the test fixture
|
||||
const fixturePath = path.join(__dirname, "..", "fixtures", "small-image.excalidraw");
|
||||
const fixtureContent = fs.readFileSync(fixturePath, "utf-8");
|
||||
const fixtureData = JSON.parse(fixtureContent);
|
||||
|
||||
// Create drawing via API with fixture data
|
||||
const createdDrawing = await createDrawing(request, {
|
||||
name: "E2E Test - Imported Image",
|
||||
files: fixtureData.files,
|
||||
});
|
||||
testDrawingIds.push(createdDrawing.id);
|
||||
|
||||
|
||||
// Create drawing via API with fixture data
|
||||
const createdDrawing = await createDrawing(request, {
|
||||
name: "E2E Test - Imported Image",
|
||||
files: fixtureData.files,
|
||||
});
|
||||
testDrawingIds.push(createdDrawing.id);
|
||||
|
||||
// Verify via API that image data was preserved
|
||||
const drawing = await getDrawing(request, createdDrawing.id);
|
||||
const drawing = await getDrawing(request, createdDrawing.id);
|
||||
const savedFiles = drawing.files || {}; // Already parsed by API
|
||||
|
||||
|
||||
expect(savedFiles["embedded-test-image"]).toBeDefined();
|
||||
expect(savedFiles["embedded-test-image"].dataURL).toBe(fixtureData.files["embedded-test-image"].dataURL);
|
||||
});
|
||||
@@ -161,23 +167,23 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
|
||||
created: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const createdDrawing = await createDrawing(request, {
|
||||
name: "E2E Test - Multiple Images",
|
||||
files,
|
||||
});
|
||||
testDrawingIds.push(createdDrawing.id);
|
||||
|
||||
|
||||
const drawing = await getDrawing(request, createdDrawing.id);
|
||||
const savedFiles = drawing.files || {}; // Already parsed by API
|
||||
|
||||
|
||||
// Verify all images preserved correctly
|
||||
for (const [id, originalFile] of Object.entries(files)) {
|
||||
expect(savedFiles[id]).toBeDefined();
|
||||
expect(savedFiles[id].dataURL).toBe((originalFile as any).dataURL);
|
||||
expect(savedFiles[id].dataURL.length).toBe((originalFile as any).dataURL.length);
|
||||
}
|
||||
|
||||
|
||||
console.log("✓ Multiple images of varying sizes preserved correctly");
|
||||
});
|
||||
});
|
||||
@@ -192,10 +198,11 @@ test.describe("Security - Malicious Content Blocking", () => {
|
||||
created: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const response = await request.post(`${API_URL}/drawings`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(await getCsrfHeaders(request)),
|
||||
},
|
||||
data: {
|
||||
name: "Security Test - JS URL",
|
||||
@@ -205,7 +212,7 @@ test.describe("Security - Malicious Content Blocking", () => {
|
||||
preview: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
console.error(`API Error: ${response.status()} - ${text}`);
|
||||
@@ -213,12 +220,14 @@ test.describe("Security - Malicious Content Blocking", () => {
|
||||
expect(response.ok()).toBe(true);
|
||||
const drawing = await response.json();
|
||||
const savedFiles = drawing.files; // Already parsed by API
|
||||
|
||||
|
||||
// The malicious URL should be blocked/cleared
|
||||
expect(savedFiles["malicious-image"].dataURL).not.toContain("javascript:");
|
||||
|
||||
|
||||
// Cleanup
|
||||
await request.delete(`${API_URL}/drawings/${drawing.id}`);
|
||||
await request.delete(`${API_URL}/drawings/${drawing.id}`, {
|
||||
headers: await getCsrfHeaders(request),
|
||||
});
|
||||
});
|
||||
|
||||
test("should block script tags in image data", async ({ request }) => {
|
||||
@@ -230,10 +239,11 @@ test.describe("Security - Malicious Content Blocking", () => {
|
||||
created: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const response = await request.post(`${API_URL}/drawings`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(await getCsrfHeaders(request)),
|
||||
},
|
||||
data: {
|
||||
name: "Security Test - Script Tag",
|
||||
@@ -243,7 +253,7 @@ test.describe("Security - Malicious Content Blocking", () => {
|
||||
preview: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
console.error(`API Error: ${response.status()} - ${text}`);
|
||||
@@ -251,11 +261,13 @@ test.describe("Security - Malicious Content Blocking", () => {
|
||||
expect(response.ok()).toBe(true);
|
||||
const drawing = await response.json();
|
||||
const savedFiles = drawing.files; // Already parsed by API
|
||||
|
||||
|
||||
// The script tag should be blocked
|
||||
expect(savedFiles["malicious-image"].dataURL).not.toContain("<script>");
|
||||
|
||||
|
||||
// Cleanup
|
||||
await request.delete(`${API_URL}/drawings/${drawing.id}`);
|
||||
await request.delete(`${API_URL}/drawings/${drawing.id}`, {
|
||||
headers: await getCsrfHeaders(request),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
createDrawing,
|
||||
deleteDrawing,
|
||||
listDrawings,
|
||||
} from "./helpers/api";
|
||||
|
||||
/**
|
||||
@@ -21,7 +20,7 @@ test.describe("Search Drawings", () => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -47,10 +46,10 @@ test.describe("Search Drawings", () => {
|
||||
|
||||
// Search for the prefix - should show only matching drawings
|
||||
await searchInput.fill(prefix);
|
||||
|
||||
|
||||
// Wait for search to apply (debounced)
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
|
||||
// Verify only matching drawings are shown
|
||||
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
|
||||
await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible();
|
||||
@@ -59,7 +58,7 @@ test.describe("Search Drawings", () => {
|
||||
// Search for specific drawing
|
||||
await searchInput.fill(`${prefix}_Alpha`);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
|
||||
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
|
||||
await expect(page.locator(`#drawing-card-${drawing2.id}`)).not.toBeVisible();
|
||||
});
|
||||
@@ -92,7 +91,7 @@ test.describe("Search Drawings", () => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const searchInput = page.getByPlaceholder("Search drawings...");
|
||||
|
||||
|
||||
// Search for one drawing
|
||||
await searchInput.fill(`${prefix}_One`);
|
||||
await page.waitForTimeout(500);
|
||||
@@ -105,7 +104,7 @@ test.describe("Search Drawings", () => {
|
||||
// Search for prefix to find both
|
||||
await searchInput.fill(prefix);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
|
||||
// Both should be visible now
|
||||
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
|
||||
await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible();
|
||||
@@ -119,10 +118,10 @@ test.describe("Search Drawings", () => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const searchInput = page.getByPlaceholder("Search drawings...");
|
||||
|
||||
|
||||
// Use keyboard shortcut (Cmd+K on Mac, Ctrl+K on Windows/Linux)
|
||||
await page.keyboard.press("Meta+k");
|
||||
|
||||
|
||||
// Search input should be focused
|
||||
await expect(searchInput).toBeFocused();
|
||||
});
|
||||
@@ -135,7 +134,7 @@ test.describe("Sort Drawings", () => {
|
||||
for (const id of createdDrawingIds) {
|
||||
try {
|
||||
await deleteDrawing(request, id);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
@@ -144,7 +143,7 @@ test.describe("Sort Drawings", () => {
|
||||
|
||||
test("should sort drawings by name", async ({ page, request }) => {
|
||||
const prefix = `SortTest_${Date.now()}`;
|
||||
|
||||
|
||||
// Create drawings with names that sort in a specific order
|
||||
const [drawingC, drawingA, drawingB] = await Promise.all([
|
||||
createDrawing(request, { name: `${prefix}_Charlie` }),
|
||||
@@ -176,7 +175,7 @@ test.describe("Sort Drawings", () => {
|
||||
|
||||
test("should toggle sort direction on repeated clicks", async ({ page, request }) => {
|
||||
const prefix = `ToggleSortTest_${Date.now()}`;
|
||||
|
||||
|
||||
const [drawingA, drawingZ] = await Promise.all([
|
||||
createDrawing(request, { name: `${prefix}_AAA` }),
|
||||
createDrawing(request, { name: `${prefix}_ZZZ` }),
|
||||
@@ -191,11 +190,11 @@ test.describe("Sort Drawings", () => {
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const nameSortButton = page.getByRole("button", { name: "Name" });
|
||||
|
||||
|
||||
// First click - ascending (A first)
|
||||
await nameSortButton.click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
|
||||
let cards = page.locator("[id^='drawing-card-']");
|
||||
let firstCard = cards.first();
|
||||
await expect(firstCard).toHaveId(`drawing-card-${drawingA.id}`);
|
||||
@@ -203,7 +202,7 @@ test.describe("Sort Drawings", () => {
|
||||
// Second click - descending (Z first)
|
||||
await nameSortButton.click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
|
||||
cards = page.locator("[id^='drawing-card-']");
|
||||
firstCard = cards.first();
|
||||
await expect(firstCard).toHaveId(`drawing-card-${drawingZ.id}`);
|
||||
@@ -211,13 +210,13 @@ test.describe("Sort Drawings", () => {
|
||||
|
||||
test("should sort by date created", async ({ page, request }) => {
|
||||
const prefix = `DateSortTest_${Date.now()}`;
|
||||
|
||||
|
||||
// Create drawings sequentially to ensure different creation times
|
||||
const drawing1 = await createDrawing(request, { name: `${prefix}_First` });
|
||||
createdDrawingIds.push(drawing1.id);
|
||||
|
||||
|
||||
await page.waitForTimeout(100); // Ensure different timestamps
|
||||
|
||||
|
||||
const drawing2 = await createDrawing(request, { name: `${prefix}_Second` });
|
||||
createdDrawingIds.push(drawing2.id);
|
||||
|
||||
@@ -241,7 +240,7 @@ test.describe("Sort Drawings", () => {
|
||||
|
||||
test("should sort by date modified", async ({ page, request }) => {
|
||||
const prefix = `ModifiedSortTest_${Date.now()}`;
|
||||
|
||||
|
||||
const [drawing1, drawing2] = await Promise.all([
|
||||
createDrawing(request, { name: `${prefix}_One` }),
|
||||
createDrawing(request, { name: `${prefix}_Two` }),
|
||||
|
||||
Reference in New Issue
Block a user