Compare commits

..

7 Commits

Author SHA1 Message Date
dependabot[bot] 9170930e8e Bump lodash-es from 4.17.21 to 4.17.23 in /frontend
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-22 01:37:02 +00:00
Zimeng Xiong 81918b00cd chore: release v0.3.1 2026-01-20 13:41:22 -08:00
Zimeng Xiong 3b384dc5fb CSRF token validation failing behind nginx proxy (#38)
Express was not configured to trust proxy headers, causing req.ip to return nginx's internal container IP instead of the actual client IP. In Docker environments, nginx can appear with different internal IPs between requests, causing the CSRF clientId to change and token validation to fail.
2026-01-20 13:39:33 -08:00
Zimeng Xiong 7c238701b7 Update RELEASE.md with CSRF_SECRET instructions (#33)
Added instructions for the required CSRF_SECRET environment variable for CSRF protection in Kubernetes deployments.
2026-01-14 13:11:25 -08:00
Zimeng Xiong c5c8b15e75 Update README header to remove version number
Removed version number from README header.
2026-01-14 13:10:43 -08:00
Zimeng Xiong 9bc3c7c8fc chore: release v0.3.0 2026-01-14 11:26:20 -08:00
Zimeng Xiong 0476315322 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>
2026-01-14 11:25:27 -08:00
37 changed files with 2103 additions and 707 deletions
+2
View File
@@ -31,7 +31,9 @@ backend/dist/
# E2E Testing # E2E Testing
e2e/node_modules/ e2e/node_modules/
e2e/test-results/ e2e/test-results/
e2e/test-results-user/
e2e/playwright-report/ e2e/playwright-report/
e2e/playwright-report-user/
e2e/.playwright/ e2e/.playwright/
# Temporary files # Temporary files
+1 -1
View File
@@ -148,7 +148,7 @@ ExcaliDash/
**Backend (.env):** **Backend (.env):**
```bash ```bash
DATABASE_URL="file:./prisma/dev.db" DATABASE_URL="file:./dev.db"
PORT=8000 PORT=8000
NODE_ENV=development NODE_ENV=development
``` ```
+550
View File
File diff suppressed because it is too large Load Diff
+22 -2
View File
@@ -1,6 +1,6 @@
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88"> <img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
# ExcaliDash v0.1.8 # ExcaliDash
![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash) ![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash)
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg) ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)
@@ -22,6 +22,8 @@ A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excali
- [Installation](#installation) - [Installation](#installation)
- [Docker Hub (Recommended)](#dockerhub-recommended) - [Docker Hub (Recommended)](#dockerhub-recommended)
- [Docker Build](#docker-build) - [Docker Build](#docker-build)
- [Reverse Proxy / Traefik Setups](#reverse-proxy--traefik-setups-docker)
- [Multi-Container / Kubernetes Deployments](#multi-container--kubernetes-deployments)
- [Development](#development) - [Development](#development)
- [Clone the Repository](#clone-the-repository) - [Clone the Repository](#clone-the-repository)
- [Frontend](#frontend) - [Frontend](#frontend)
@@ -75,7 +77,7 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp
# Installation # Installation
> [!CAUTION] > [!CAUTION]
> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports. Currently lacking CSRF. > NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports.
> [!CAUTION] > [!CAUTION]
> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron). > ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron).
@@ -134,6 +136,24 @@ frontend:
- BACKEND_URL=excalidash-backend.default.svc.cluster.local:8000 - BACKEND_URL=excalidash-backend.default.svc.cluster.local:8000
``` ```
### Multi-Container / Kubernetes Deployments
When running multiple backend replicas (e.g., Kubernetes, Docker Swarm, or load-balanced containers), you **must** set the `CSRF_SECRET` environment variable to the same value across all instances.
```bash
# Generate a secure secret
openssl rand -base64 32
```
```yaml
# docker-compose.yml or k8s deployment
backend:
environment:
- CSRF_SECRET=your-generated-secret-here
```
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
# Development # Development
## Clone the Repository ## Clone the Repository
+31 -18
View File
@@ -1,30 +1,43 @@
# ExcaliDash v0.1.5 CSRF Protection (8a78b2b)
Date: 2025-11-23 - Implemented comprehensive CSRF (Cross-Site Request Forgery) protection for enhanced security
- Added new backend/src/security.ts module for security utilities
- Frontend API layer now handles CSRF tokens automatically
- Added integration tests for CSRF validation
Compatibility: v0.1.x (Backward Compatible) Upload Progress Indicator (8f9b9b4)
# Security - Added a visual upload progress bar when users upload files
- New UploadContext for managing upload state across components
- New UploadStatus component displaying real-time upload progress
- Save status indicator when navigating back from the editor
- Improved error handling and recovery for failed uploads
- RCE: implemented strict Zod schema validation and input sanitization on file uploads; added path traversal guards to file handling logic Bug Fixes
- XSS: used DOMPurify for HTML sanitization; blocked execution-capable SVG attributes and enforces CSP headers. - Fixed broken e2e tests (cae8f3c)
- Replaced deprecated substr() with substring()
- Fixed stale state issues in error handling
- Fixed missing useEffect dependencies
- Fixed CSS class conflicts in progress bar styling
- Added error recovery for save state in Editor
- DoS: moved CPU-intensive operations to worker threads to prevent event loop blocking; request rate limiting (1,000 req/15 min per IP) and streaming for large files Infrastructure
# Infras & Deployment - Updated docker-compose configurations with new environment variables
- E2E test suite improvements and reliability fixes
- Added Kubernetes deployment note in README
- non-root execution (uid 1001) in containers ### Kubernetes
- migrated to multi-stage Docker builds
# Database A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string:
- migrated to better-sqlite3, converted all DB interactions to non-blocking async operations and offloaded integrity checks to worker threads. ```bash
openssl rand -base64 32
- implemented SQLite magic header validation; added automatic backup triggers preceding data import Add it to your deployment:
- Docker Compose: Add CSRF_SECRET=<your-secret> to the backend service environment
- Kubernetes: Add to your ConfigMap/Secret and reference in the backend deployment
- input validation logic If not set, the backend will refuse to start.
```
# Frontend
- updated Settings UI to show version
+1 -1
View File
@@ -1 +1 @@
0.1.8 0.3.1
+197 -365
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "0.1.8", "version": "0.3.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -16,7 +16,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@types/jsdom": "^27.0.0", "@types/jsdom": "^21.1.7",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/socket.io": "^3.0.1", "@types/socket.io": "^3.0.1",
"archiver": "^7.0.1", "archiver": "^7.0.1",
@@ -25,7 +25,7 @@
"dompurify": "^3.3.0", "dompurify": "^3.3.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
"jsdom": "^27.2.0", "jsdom": "^22.1.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
+168
View File
@@ -0,0 +1,168 @@
/**
* CSRF Tests - Horizontal Scaling (K8s) Validation
*
* PR #20 review concern:
* "Worried that in memory token store might not work on horizontal scaling"
*
* Fix:
* - CSRF tokens are now stateless and HMAC-signed using a shared `CSRF_SECRET`.
* - Any pod can validate any token as long as all pods share the same secret.
*
* These tests prove:
* - Tokens validate correctly for the issuing client id
* - Tokens do NOT validate for a different client id
* - Tokens expire after 24 hours
* - Tokens validate across separate module instances (simulated pods)
*/
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
const SHARED_SECRET = "test-shared-csrf-secret";
beforeAll(() => {
// Must be shared across instances/pods for horizontal scaling.
process.env.CSRF_SECRET = SHARED_SECRET;
});
afterEach(() => {
vi.useRealTimers();
});
describe("CSRF - stateless HMAC tokens", () => {
it("creates a token in payload.signature format and validates for same client id", async () => {
const { createCsrfToken, validateCsrfToken } = await import("../security");
const clientId = "test-client-1";
const token = createCsrfToken(clientId);
expect(typeof token).toBe("string");
// base64url(payload).base64url(signature)
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
expect(validateCsrfToken(clientId, token)).toBe(true);
});
it("rejects validation for a different client id (token binding)", async () => {
const { createCsrfToken, validateCsrfToken } = await import("../security");
const token = createCsrfToken("client-a");
expect(validateCsrfToken("client-b", token)).toBe(false);
});
it("rejects malformed tokens", async () => {
const { validateCsrfToken } = await import("../security");
expect(validateCsrfToken("client", "not-a-token")).toBe(false);
expect(validateCsrfToken("client", "a.b.c")).toBe(false);
expect(validateCsrfToken("client", "")).toBe(false);
});
it("revokeCsrfToken is a no-op for stateless tokens (does not break callers)", async () => {
const { createCsrfToken, validateCsrfToken, revokeCsrfToken } = await import(
"../security"
);
const clientId = "client-revoke";
const token = createCsrfToken(clientId);
expect(validateCsrfToken(clientId, token)).toBe(true);
revokeCsrfToken(clientId);
// Stateless token remains valid until expiry
expect(validateCsrfToken(clientId, token)).toBe(true);
});
it("expires tokens after 24 hours", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const { createCsrfToken, validateCsrfToken } = await import("../security");
const clientId = "client-expiry";
const token = createCsrfToken(clientId);
expect(validateCsrfToken(clientId, token)).toBe(true);
// 24h + 1ms later
vi.setSystemTime(new Date("2025-01-02T00:00:00.001Z"));
expect(validateCsrfToken(clientId, token)).toBe(false);
});
});
describe("CSRF - horizontal scaling (simulated pods)", () => {
it("validates across module instances (pod A issues, pod B validates)", async () => {
const clientId = "user-123";
vi.resetModules();
const podA = await import("../security");
const token = podA.createCsrfToken(clientId);
// Simulate a different pod (new Node.js process / fresh module state)
vi.resetModules();
const podB = await import("../security");
expect(podB.validateCsrfToken(clientId, token)).toBe(true);
});
it("has 0% failure rate under round-robin validation across 3 pods", async () => {
const clientId = "user-round-robin";
const pods: Array<{
createCsrfToken: (clientId: string) => string;
validateCsrfToken: (clientId: string, token: string) => boolean;
}> = [];
for (let i = 0; i < 3; i++) {
vi.resetModules();
pods.push(await import("../security"));
}
// Token issued on one pod
const token = pods[0].createCsrfToken(clientId);
// Validate on alternating pods (simulates a non-sticky load balancer)
const attempts = 60;
let failures = 0;
for (let i = 0; i < attempts; i++) {
const pod = pods[i % pods.length];
if (!pod.validateCsrfToken(clientId, token)) failures++;
}
expect(failures).toBe(0);
});
});
describe("CSRF - referer origin parsing", () => {
it("extracts exact origin from a referer URL", async () => {
const { getOriginFromReferer } = await import("../security");
expect(getOriginFromReferer("https://example.com/path?x=1")).toBe(
"https://example.com"
);
expect(getOriginFromReferer("http://localhost:5173/some/page")).toBe(
"http://localhost:5173"
);
});
it("does not allow prefix tricks (origin must be parsed)", async () => {
const { getOriginFromReferer } = await import("../security");
expect(
getOriginFromReferer("https://example.com.evil.com/anything")
).toBe("https://example.com.evil.com");
// `startsWith("https://example.com")` would incorrectly allow this.
expect(getOriginFromReferer("https://example.com@evil.com/anything")).toBe(
"https://evil.com"
);
});
it("returns null for invalid or non-http(s) referers", async () => {
const { getOriginFromReferer } = await import("../security");
expect(getOriginFromReferer("")).toBeNull();
expect(getOriginFromReferer("not a url")).toBeNull();
expect(getOriginFromReferer("file:///etc/passwd")).toBeNull();
expect(getOriginFromReferer(null)).toBeNull();
});
});
+164 -15
View File
@@ -18,6 +18,10 @@ import {
sanitizeSvg, sanitizeSvg,
elementSchema, elementSchema,
appStateSchema, appStateSchema,
createCsrfToken,
validateCsrfToken,
getCsrfTokenHeader,
getOriginFromReferer,
} from "./security"; } from "./security";
dotenv.config(); dotenv.config();
@@ -34,9 +38,22 @@ const resolveDatabaseUrl = (rawUrl?: string) => {
} }
const filePath = rawUrl.replace(/^file:/, ""); const filePath = rawUrl.replace(/^file:/, "");
// Prisma treats relative SQLite paths as relative to the schema directory
// (i.e. `backend/prisma/schema.prisma`). Historically this project used
// `file:./prisma/dev.db`, which Prisma interprets as `prisma/prisma/dev.db`.
// To keep runtime and migrations aligned:
// - Prefer resolving relative paths against `backend/prisma`
// - But if the path already includes a leading `prisma/`, resolve from repo root
const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir =
normalizedRelative === "prisma" ||
normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath) const absolutePath = path.isAbsolute(filePath)
? filePath ? filePath
: path.resolve(backendRoot, filePath); : path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
return `file:${absolutePath}`; return `file:${absolutePath}`;
}; };
@@ -63,11 +80,15 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => {
const ensureProtocol = (origin: string) => const ensureProtocol = (origin: string) =>
/^https?:\/\//i.test(origin) ? origin : `http://${origin}`; /^https?:\/\//i.test(origin) ? origin : `http://${origin}`;
const removeTrailingSlash = (origin: string) =>
origin.endsWith("/") ? origin.slice(0, -1) : origin;
const parsed = rawOrigins const parsed = rawOrigins
.split(",") .split(",")
.map((origin) => origin.trim()) .map((origin) => origin.trim())
.filter((origin) => origin.length > 0) .filter((origin) => origin.length > 0)
.map(ensureProtocol); .map(ensureProtocol)
.map(removeTrailingSlash);
return parsed.length > 0 ? parsed : [fallback]; return parsed.length > 0 ? parsed : [fallback];
}; };
@@ -108,6 +129,12 @@ const initializeUploadDir = async () => {
}; };
const app = express(); const app = express();
// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx
// Required for correct client IP detection when running behind a reverse proxy
// This fixes CSRF token validation failures in Docker/K8s environments
app.set("trust proxy", 1);
const httpServer = createServer(app); const httpServer = createServer(app);
const io = new Server(httpServer, { const io = new Server(httpServer, {
cors: { cors: {
@@ -211,6 +238,8 @@ app.use(
cors({ cors({
origin: allowedOrigins, origin: allowedOrigins,
credentials: true, credentials: true,
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
exposedHeaders: ["x-csrf-token"],
}) })
); );
app.use(express.json({ limit: "50mb" })); app.use(express.json({ limit: "50mb" }));
@@ -244,12 +273,12 @@ app.use((req, res, next) => {
res.setHeader( res.setHeader(
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; " + "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " + "font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: blob: https:; " + "img-src 'self' data: blob: https:; " +
"connect-src 'self' ws: wss:; " + "connect-src 'self' ws: wss:; " +
"frame-ancestors 'none';" "frame-ancestors 'none';"
); );
next(); next();
@@ -296,6 +325,128 @@ app.use((req, res, next) => {
next(); next();
}); });
// CSRF Protection Middleware
// Generates a unique client ID based on IP and User-Agent for token association
const getClientId = (req: express.Request): string => {
const ip = req.ip || req.connection.remoteAddress || "unknown";
const userAgent = req.headers["user-agent"] || "unknown";
// Create a simple hash for client identification
// In production, you might use a session ID instead
return `${ip}:${userAgent}`.slice(0, 256);
};
// Rate limiter specifically for CSRF token generation to prevent store exhaustion
const csrfRateLimit = new Map<string, { count: number; resetTime: number }>();
const CSRF_RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const CSRF_MAX_REQUESTS = (() => {
const parsed = Number(process.env.CSRF_MAX_REQUESTS);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 60; // 1 per second average
}
return parsed;
})();
// CSRF token endpoint - clients should call this to get a token
app.get("/csrf-token", (req, res) => {
const ip = req.ip || req.connection.remoteAddress || "unknown";
const now = Date.now();
const clientLimit = csrfRateLimit.get(ip);
if (clientLimit && now < clientLimit.resetTime) {
if (clientLimit.count >= CSRF_MAX_REQUESTS) {
return res.status(429).json({
error: "Rate limit exceeded",
message: "Too many CSRF token requests",
});
}
clientLimit.count++;
} else {
csrfRateLimit.set(ip, { count: 1, resetTime: now + CSRF_RATE_LIMIT_WINDOW });
}
// Cleanup old rate limit entries occasionally
if (Math.random() < 0.01) {
for (const [key, data] of csrfRateLimit.entries()) {
if (now > data.resetTime) csrfRateLimit.delete(key);
}
}
const clientId = getClientId(req);
const token = createCsrfToken(clientId);
res.json({
token,
header: getCsrfTokenHeader()
});
});
// CSRF validation middleware for state-changing requests
const csrfProtectionMiddleware = (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
// Skip CSRF validation for safe methods (GET, HEAD, OPTIONS)
// Note: /csrf-token is a GET endpoint, so it's automatically exempt
const safeMethods = ["GET", "HEAD", "OPTIONS"];
if (safeMethods.includes(req.method)) {
return next();
}
// Origin/Referer check for defense in depth
const origin = req.headers["origin"];
const referer = req.headers["referer"];
// If Origin is present, it must match allowed origins
const originValue = Array.isArray(origin) ? origin[0] : origin;
const refererValue = Array.isArray(referer) ? referer[0] : referer;
if (originValue) {
if (!allowedOrigins.includes(originValue)) {
return res.status(403).json({
error: "CSRF origin mismatch",
message: "Origin not allowed",
});
}
} else if (refererValue) {
// If no Origin but Referer exists, validate its *origin* (avoid prefix bypass)
const refererOrigin = getOriginFromReferer(refererValue);
if (!refererOrigin || !allowedOrigins.includes(refererOrigin)) {
return res.status(403).json({
error: "CSRF referer mismatch",
message: "Referer not allowed",
});
}
}
// Note: If neither Origin nor Referer is present, we proceed to token check.
// Some legitimate clients/proxies might strip these, so we don't block strictly on their absence,
// but relying on the token is the primary defense.
const clientId = getClientId(req);
const headerName = getCsrfTokenHeader();
const tokenHeader = req.headers[headerName];
const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
if (!token) {
return res.status(403).json({
error: "CSRF token missing",
message: `Missing ${headerName} header`,
});
}
if (!validateCsrfToken(clientId, token)) {
return res.status(403).json({
error: "CSRF token invalid",
message: "Invalid or expired CSRF token. Please refresh and try again.",
});
}
next();
};
// Apply CSRF protection to all routes
app.use(csrfProtectionMiddleware);
const filesFieldSchema = z const filesFieldSchema = z
.union([z.record(z.string(), z.any()), z.null()]) .union([z.record(z.string(), z.any()), z.null()])
.optional() .optional()
@@ -922,8 +1073,7 @@ app.get("/export", async (req, res) => {
res.setHeader("Content-Type", "application/octet-stream"); res.setHeader("Content-Type", "application/octet-stream");
res.setHeader( res.setHeader(
"Content-Disposition", "Content-Disposition",
`attachment; filename="excalidash-db-${ `attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0]
new Date().toISOString().split("T")[0]
}.${extension}"` }.${extension}"`
); );
@@ -946,8 +1096,7 @@ app.get("/export/json", async (req, res) => {
res.setHeader("Content-Type", "application/zip"); res.setHeader("Content-Type", "application/zip");
res.setHeader( res.setHeader(
"Content-Disposition", "Content-Disposition",
`attachment; filename="excalidraw-drawings-${ `attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0]
new Date().toISOString().split("T")[0]
}.zip"` }.zip"`
); );
@@ -1012,8 +1161,8 @@ Total Drawings: ${drawings.length}
Collections: Collections:
${Object.entries(drawingsByCollection) ${Object.entries(drawingsByCollection)
.map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`) .map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`)
.join("\n")} .join("\n")}
`; `;
archive.append(readmeContent, { name: "README.txt" }); archive.append(readmeContent, { name: "README.txt" });
@@ -1085,7 +1234,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
try { try {
await fsPromises.access(dbPath); await fsPromises.access(dbPath);
await fsPromises.copyFile(dbPath, backupPath); await fsPromises.copyFile(dbPath, backupPath);
} catch {} } catch { }
await moveFile(stagedPath, dbPath); await moveFile(stagedPath, dbPath);
} catch (error) { } catch (error) {
+180
View File
@@ -1,6 +1,10 @@
/**
* Security utilities for XSS prevention, data sanitization, and CSRF protection
*/
import { z } from "zod"; import { z } from "zod";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import crypto from "crypto";
// Create a DOM environment for DOMPurify (Node.js compatibility) // Create a DOM environment for DOMPurify (Node.js compatibility)
const window = new JSDOM("").window; const window = new JSDOM("").window;
@@ -523,3 +527,179 @@ export const validateImportedDrawing = (data: any): boolean => {
return false; return false;
} }
}; };
// ============================================================================
// CSRF Protection
// ============================================================================
const CSRF_TOKEN_HEADER = "x-csrf-token";
const CSRF_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const CSRF_TOKEN_FUTURE_SKEW_MS = 5 * 60 * 1000; // 5 minutes clock skew tolerance
const CSRF_NONCE_BYTES = 16;
const CSRF_TOKEN_MAX_LENGTH = 2048; // sanity limit against abuse
/**
* IMPORTANT (Horizontal Scaling / K8s)
* -----------------------------------
* CSRF tokens must validate across multiple stateless instances.
*
* The prior in-memory Map-based token store breaks under horizontal scaling
* because each pod has its own memory. This implementation is stateless:
*
* - Token payload: { ts, nonce }
* - Signature: HMAC_SHA256(secret, `${clientId}|${ts}|${nonce}`)
*
* As long as all pods share the same `CSRF_SECRET`, any pod can validate
* any token without shared state (works on Kubernetes).
*/
let cachedCsrfSecret: Buffer | null = null;
const getCsrfSecret = (): Buffer => {
if (cachedCsrfSecret) return cachedCsrfSecret;
const secretFromEnv = process.env.CSRF_SECRET;
if (secretFromEnv && secretFromEnv.trim().length > 0) {
cachedCsrfSecret = Buffer.from(secretFromEnv, "utf8");
return cachedCsrfSecret;
}
// If not configured, generate an ephemeral secret for this process.
// This keeps single-instance deployments working out of the box, but:
// - Horizontal scaling will BREAK unless CSRF_SECRET is set and shared.
cachedCsrfSecret = crypto.randomBytes(32);
const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : "";
console.warn(
`[security] CSRF_SECRET is not set${envLabel}. Using an ephemeral per-process secret. ` +
"For horizontal scaling (k8s), set CSRF_SECRET to the same value on all instances."
);
return cachedCsrfSecret;
};
const base64UrlEncode = (input: Buffer | string): string => {
const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
};
const base64UrlDecode = (input: string): Buffer => {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return Buffer.from(padded, "base64");
};
type CsrfTokenPayload = {
/** Issued-at timestamp (ms since epoch) */
ts: number;
/** Random nonce (base64url) */
nonce: string;
};
const signCsrfToken = (clientId: string, payload: CsrfTokenPayload): Buffer => {
const secret = getCsrfSecret();
const data = `${clientId}|${payload.ts}|${payload.nonce}`;
return crypto.createHmac("sha256", secret).update(data, "utf8").digest();
};
/**
* Create a new CSRF token for a client
* Returns the token to be sent to the client
*/
export const createCsrfToken = (clientId: string): string => {
const payload: CsrfTokenPayload = {
ts: Date.now(),
nonce: base64UrlEncode(crypto.randomBytes(CSRF_NONCE_BYTES)),
};
const payloadJson = JSON.stringify(payload);
const payloadB64 = base64UrlEncode(payloadJson);
const sigB64 = base64UrlEncode(signCsrfToken(clientId, payload));
return `${payloadB64}.${sigB64}`;
};
/**
* Validate a CSRF token for a client
* Uses timing-safe comparison to prevent timing attacks
*/
export const validateCsrfToken = (clientId: string, token: string): boolean => {
if (!token || typeof token !== "string") {
return false;
}
if (token.length > CSRF_TOKEN_MAX_LENGTH) {
return false;
}
try {
const parts = token.split(".");
if (parts.length !== 2) return false;
const [payloadB64, sigB64] = parts;
const payloadJson = base64UrlDecode(payloadB64).toString("utf8");
const payload = JSON.parse(payloadJson) as Partial<CsrfTokenPayload>;
if (
typeof payload.ts !== "number" ||
!Number.isFinite(payload.ts) ||
typeof payload.nonce !== "string" ||
payload.nonce.length < 8
) {
return false;
}
const now = Date.now();
// Expiry check
if (now - payload.ts > CSRF_TOKEN_EXPIRY_MS) return false;
// Future skew check (clock mismatch)
if (payload.ts - now > CSRF_TOKEN_FUTURE_SKEW_MS) return false;
const expectedSig = signCsrfToken(clientId, {
ts: payload.ts,
nonce: payload.nonce,
});
const providedSig = base64UrlDecode(sigB64);
if (providedSig.length !== expectedSig.length) return false;
return crypto.timingSafeEqual(providedSig, expectedSig);
} catch {
return false;
}
};
/**
* Revoke a CSRF token (e.g., on logout or token refresh)
*/
export const revokeCsrfToken = (clientId: string): void => {
// Stateless CSRF tokens cannot be selectively revoked without shared state.
// If revocation is required, implement token blacklisting in a shared store
// (e.g., Redis) or rotate CSRF_SECRET.
void clientId;
};
/**
* Get the CSRF token header name
*/
export const getCsrfTokenHeader = (): string => {
return CSRF_TOKEN_HEADER;
};
export const getOriginFromReferer = (referer: unknown): string | null => {
if (typeof referer !== "string" || referer.trim().length === 0) {
return null;
}
try {
const url = new URL(referer);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
};
+2
View File
@@ -6,6 +6,8 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db - DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000 - PORT=8000
- NODE_ENV=production - NODE_ENV=production
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
# - CSRF_SECRET=${CSRF_SECRET}
volumes: volumes:
- backend-data:/app/prisma - backend-data:/app/prisma
networks: networks:
+2
View File
@@ -8,6 +8,8 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db - DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000 - PORT=8000
- NODE_ENV=production - NODE_ENV=production
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
# - CSRF_SECRET=${CSRF_SECRET}
volumes: volumes:
- backend-data:/app/prisma - backend-data:/app/prisma
networks: networks:
+1 -1
View File
@@ -1,5 +1,5 @@
# Playwright E2E Test Runner # Playwright E2E Test Runner
FROM mcr.microsoft.com/playwright:v1.52.0-noble FROM mcr.microsoft.com/playwright:v1.57.0-noble
WORKDIR /app WORKDIR /app
+13 -8
View File
@@ -17,14 +17,18 @@ services:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
environment: 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 - PORT=8000
- NODE_ENV=test - 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: ports:
- "8000:8000" - "8000:8000"
healthcheck: 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 interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -35,17 +39,18 @@ services:
# Frontend web server # Frontend web server
frontend: frontend:
build: build:
context: ../frontend # Use the repo root as build context because `frontend/Dockerfile` expects
dockerfile: Dockerfile # `frontend/...` paths (same as production `docker-compose.yml`).
args: context: ..
- VITE_API_URL=http://backend:8000 dockerfile: frontend/Dockerfile
ports: ports:
- "5173:80" - "5173:80"
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
healthcheck: 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 interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
+28 -18
View File
@@ -17,49 +17,56 @@ const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
*/ */
export default defineConfig({ export default defineConfig({
testDir: "./tests", testDir: "./tests",
// Run tests in parallel // Run tests in parallel
fullyParallel: true, fullyParallel: true,
// Fail the build on test.only() in CI // Fail the build on test.only() in CI
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
// Retry on CI only // Retry on CI only
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
// Limit parallel workers in CI // Limit parallel workers in CI
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
// Reporter configuration // Reporter configuration
reporter: [ reporter: [
["list"], ["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 // Output folder for test artifacts
outputDir: "test-results", outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR || "test-results",
// Global timeout for each test // Global timeout for each test
timeout: 60000, timeout: 60000,
// Expect timeout // Expect timeout
expect: { expect: {
timeout: 10000, timeout: 10000,
}, },
use: { use: {
// Base URL for page.goto() // Base URL for page.goto()
baseURL: FRONTEND_URL, baseURL: FRONTEND_URL,
// Collect trace on first retry // Collect trace on first retry
trace: "on-first-retry", trace: "on-first-retry",
// Screenshot on failure // Screenshot on failure
screenshot: "only-on-failure", screenshot: "only-on-failure",
// Video on failure // Video on failure
video: "on-first-retry", video: "on-first-retry",
// Headed mode based on env var // Headed mode based on env var
headless: process.env.HEADED !== "true", headless: process.env.HEADED !== "true",
}, },
@@ -67,7 +74,7 @@ export default defineConfig({
projects: [ projects: [
{ {
name: "chromium", name: "chromium",
use: { use: {
...devices["Desktop Chrome"], ...devices["Desktop Chrome"],
// Viewport for consistent screenshots // Viewport for consistent screenshots
viewport: { width: 1280, height: 720 }, viewport: { width: 1280, height: 720 },
@@ -76,7 +83,7 @@ export default defineConfig({
], ],
// Run local dev servers before tests (skip if NO_SERVER or CI) // 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", command: "cd ../backend && npm run dev",
url: `${BACKEND_URL}/health`, url: `${BACKEND_URL}/health`,
@@ -85,8 +92,11 @@ export default defineConfig({
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
env: { 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, FRONTEND_URL,
CSRF_MAX_REQUESTS: "1000",
}, },
}, },
{ {
+11 -12
View File
@@ -1,6 +1,5 @@
import { test, expect, type BrowserContext, type Page } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { import {
API_URL,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
getDrawing, getDrawing,
@@ -22,7 +21,7 @@ test.describe("Real-time Collaboration", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -63,7 +62,7 @@ test.describe("Real-time Collaboration", () => {
// At least one page should show the other user // At least one page should show the other user
const hasCollaborator1 = await collaboratorIndicator1.count(); const hasCollaborator1 = await collaboratorIndicator1.count();
const hasCollaborator2 = await collaboratorIndicator2.count(); const hasCollaborator2 = await collaboratorIndicator2.count();
// Socket.io presence should eventually show users // Socket.io presence should eventually show users
// This test validates the socket connection works // This test validates the socket connection works
expect(hasCollaborator1 + hasCollaborator2).toBeGreaterThanOrEqual(0); 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 }) => { test("should sync drawing changes between two users", async ({ browser, request }) => {
// Create a test drawing // Create a test drawing
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `Collab_Sync_${Date.now()}`, name: `Collab_Sync_${Date.now()}`,
elements: [], elements: [],
}); });
@@ -121,10 +120,10 @@ test.describe("Real-time Collaboration", () => {
// Verify the drawing was saved (via API) // Verify the drawing was saved (via API)
const updatedDrawing = await getDrawing(request, drawing.id); const updatedDrawing = await getDrawing(request, drawing.id);
// The drawing should have elements now // The drawing should have elements now
const elements = updatedDrawing.elements || []; const elements = updatedDrawing.elements || [];
// Element sync happens via socket and periodic save // Element sync happens via socket and periodic save
// The test validates the drawing flow works end-to-end // The test validates the drawing flow works end-to-end
expect(elements).toBeDefined(); expect(elements).toBeDefined();
@@ -136,7 +135,7 @@ test.describe("Real-time Collaboration", () => {
test("should persist drawing changes across page reload", async ({ page, request }) => { test("should persist drawing changes across page reload", async ({ page, request }) => {
// Create a test drawing // Create a test drawing
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `Collab_Persist_${Date.now()}`, name: `Collab_Persist_${Date.now()}`,
elements: [], elements: [],
}); });
@@ -149,7 +148,7 @@ test.describe("Real-time Collaboration", () => {
// Draw something - use the interactive canvas layer // Draw something - use the interactive canvas layer
const canvas = page.locator("canvas.excalidraw__canvas.interactive"); const canvas = page.locator("canvas.excalidraw__canvas.interactive");
// Select rectangle tool // Select rectangle tool
await page.keyboard.press("r"); await page.keyboard.press("r");
await page.waitForTimeout(200); await page.waitForTimeout(200);
@@ -157,7 +156,7 @@ test.describe("Real-time Collaboration", () => {
// Draw a rectangle - click on the interactive canvas // Draw a rectangle - click on the interactive canvas
const box = await canvas.boundingBox(); const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found"); if (!box) throw new Error("Canvas not found");
await page.mouse.move(box.x + 150, box.y + 150); await page.mouse.move(box.x + 150, box.y + 150);
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(box.x + 350, box.y + 250, { steps: 5 }); 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 canvas1 = page1.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas1.boundingBox(); const box = await canvas1.boundingBox();
if (!box) throw new Error("Canvas not found"); if (!box) throw new Error("Canvas not found");
await page1.mouse.move(box.x + 300, box.y + 300); await page1.mouse.move(box.x + 300, box.y + 300);
await page1.waitForTimeout(500); await page1.waitForTimeout(500);
await page1.mouse.move(box.x + 400, box.y + 400); 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 // The cursor position should be broadcasted to page2
// Excalidraw shows collaborator cursors with names // Excalidraw shows collaborator cursors with names
// This test validates the socket connection for cursor sync // This test validates the socket connection for cursor sync
// Wait for potential cursor updates // Wait for potential cursor updates
await page2.waitForTimeout(1000); await page2.waitForTimeout(1000);
+2 -2
View File
@@ -45,7 +45,7 @@ test.describe("Dashboard Workflows", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (error) { } catch {
// Ignore cleanup failures to keep tests resilient // Ignore cleanup failures to keep tests resilient
} }
} }
@@ -54,7 +54,7 @@ test.describe("Dashboard Workflows", () => {
for (const id of createdCollectionIds) { for (const id of createdCollectionIds) {
try { try {
await deleteCollection(request, id); await deleteCollection(request, id);
} catch (error) { } catch {
// Ignore cleanup failures to keep tests resilient // Ignore cleanup failures to keep tests resilient
} }
} }
+17 -21
View File
@@ -2,7 +2,6 @@ import { test, expect } from "@playwright/test";
import * as path from "path"; import * as path from "path";
import * as fs from "fs"; import * as fs from "fs";
import { import {
API_URL,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
listDrawings, listDrawings,
@@ -27,7 +26,7 @@ test.describe("Drag and Drop - Collections", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -36,7 +35,7 @@ test.describe("Drag and Drop - Collections", () => {
for (const id of createdCollectionIds) { for (const id of createdCollectionIds) {
try { try {
await deleteCollection(request, id); await deleteCollection(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -61,7 +60,7 @@ test.describe("Drag and Drop - Collections", () => {
// Hover to reveal the collection picker // Hover to reveal the collection picker
await card.hover(); await card.hover();
// Click the collection picker button on the card // Click the collection picker button on the card
const collectionPicker = card.locator(`[data-testid="collection-picker-${drawing.id}"]`); const collectionPicker = card.locator(`[data-testid="collection-picker-${drawing.id}"]`);
await collectionPicker.click(); await collectionPicker.click();
@@ -76,7 +75,7 @@ test.describe("Drag and Drop - Collections", () => {
// Navigate to the collection and verify drawing is there // Navigate to the collection and verify drawing is there
await page.getByRole("navigation").getByRole("button", { name: collection.name }).click(); await page.getByRole("navigation").getByRole("button", { name: collection.name }).click();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(card).toBeVisible(); await expect(card).toBeVisible();
}); });
@@ -85,9 +84,9 @@ test.describe("Drag and Drop - Collections", () => {
const collection = await createCollection(request, `UnorgTest_Collection_${Date.now()}`); const collection = await createCollection(request, `UnorgTest_Collection_${Date.now()}`);
createdCollectionIds.push(collection.id); createdCollectionIds.push(collection.id);
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `UnorgTest_Drawing_${Date.now()}`, name: `UnorgTest_Drawing_${Date.now()}`,
collectionId: collection.id collectionId: collection.id
}); });
createdDrawingIds.push(drawing.id); createdDrawingIds.push(drawing.id);
@@ -119,7 +118,7 @@ test.describe("Drag and Drop - Collections", () => {
// Navigate to Unorganized and verify drawing is there // Navigate to Unorganized and verify drawing is there
await page.getByRole("navigation").getByRole("button", { name: "Unorganized" }).click(); await page.getByRole("navigation").getByRole("button", { name: "Unorganized" }).click();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.locator(`#drawing-card-${drawing.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing.id}`)).toBeVisible();
}); });
@@ -146,7 +145,7 @@ test.describe("Drag and Drop - Collections", () => {
// Select both drawings // Select both drawings
const card1 = page.locator(`#drawing-card-${drawing1.id}`); const card1 = page.locator(`#drawing-card-${drawing1.id}`);
const card2 = page.locator(`#drawing-card-${drawing2.id}`); const card2 = page.locator(`#drawing-card-${drawing2.id}`);
await card1.hover(); await card1.hover();
const toggle1 = card1.locator(`[data-testid="select-drawing-${drawing1.id}"]`); const toggle1 = card1.locator(`[data-testid="select-drawing-${drawing1.id}"]`);
await toggle1.click(); await toggle1.click();
@@ -186,7 +185,7 @@ test.describe("Drag and Drop - File Import", () => {
for (const drawing of drawings) { for (const drawing of drawings) {
try { try {
await deleteDrawing(request, drawing.id); await deleteDrawing(request, drawing.id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -194,7 +193,7 @@ test.describe("Drag and Drop - File Import", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -205,7 +204,7 @@ test.describe("Drag and Drop - File Import", () => {
// Note: Simulating drag events with files is unreliable in Playwright // Note: Simulating drag events with files is unreliable in Playwright
// because the DataTransfer API has security restrictions. // because the DataTransfer API has security restrictions.
// This test verifies the drop zone UI exists and can be triggered. // This test verifies the drop zone UI exists and can be triggered.
await page.goto("/"); await page.goto("/");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
@@ -218,13 +217,13 @@ test.describe("Drag and Drop - File Import", () => {
try { try {
const dt = new DataTransfer(); const dt = new DataTransfer();
dt.items.add(new File(['test'], 'test.excalidraw', { type: 'application/json' })); dt.items.add(new File(['test'], 'test.excalidraw', { type: 'application/json' }));
const event = new DragEvent('dragenter', { const event = new DragEvent('dragenter', {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
dataTransfer: dt, dataTransfer: dt,
}); });
// Find the main content area and dispatch the event // Find the main content area and dispatch the event
const main = document.querySelector('main'); const main = document.querySelector('main');
if (main) { if (main) {
@@ -242,7 +241,7 @@ test.describe("Drag and Drop - File Import", () => {
// Check that the drop zone overlay is shown // Check that the drop zone overlay is shown
const dropZone = page.getByText("Drop files to import"); const dropZone = page.getByText("Drop files to import");
const isVisible = await dropZone.isVisible().catch(() => false); const isVisible = await dropZone.isVisible().catch(() => false);
if (isVisible) { if (isVisible) {
await expect(dropZone).toBeVisible(); await expect(dropZone).toBeVisible();
} else { } 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.goto("/");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
@@ -273,11 +272,8 @@ test.describe("Drag and Drop - File Import", () => {
const fileInput = page.locator("#dashboard-import"); const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles(fixturePath); await fileInput.setInputFiles(fixturePath);
// Wait for import success modal // Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Dismiss the modal
await page.getByRole("button", { name: "OK" }).click();
// Search for the imported drawing (it uses the filename as name) // Search for the imported drawing (it uses the filename as name)
await page.getByPlaceholder("Search drawings...").fill("small-image"); await page.getByPlaceholder("Search drawings...").fill("small-image");
+29 -28
View File
@@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { import {
API_URL,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
getDrawing, getDrawing,
@@ -24,7 +25,7 @@ test.describe("Drawing Creation", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -96,7 +97,7 @@ test.describe("Drawing Creation", () => {
test("should rename drawing via editor header", async ({ page, request }) => { test("should rename drawing via editor header", async ({ page, request }) => {
const originalName = `Rename_Original_${Date.now()}`; const originalName = `Rename_Original_${Date.now()}`;
const newName = `Rename_Updated_${Date.now()}`; const newName = `Rename_Updated_${Date.now()}`;
const drawing = await createDrawing(request, { name: originalName }); const drawing = await createDrawing(request, { name: originalName });
createdDrawingIds.push(drawing.id); createdDrawingIds.push(drawing.id);
@@ -150,7 +151,7 @@ test.describe("Drawing Editing", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -158,7 +159,7 @@ test.describe("Drawing Editing", () => {
}); });
test("should draw a rectangle on canvas", async ({ page, request }) => { 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()}`, name: `Draw_Rect_${Date.now()}`,
elements: [], elements: [],
}); });
@@ -172,19 +173,19 @@ test.describe("Drawing Editing", () => {
const canvas = page.locator("canvas.excalidraw__canvas.interactive"); const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox(); const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found"); 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}`); 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 // Click on the rectangle tool using the label element
// Find the label that contains the rectangle radio button // Find the label that contains the rectangle radio button
const rectangleLabel = page.locator('label:has([data-testid="toolbar-rectangle"])'); const rectangleLabel = page.locator('label:has([data-testid="toolbar-rectangle"])');
await rectangleLabel.click(); await rectangleLabel.click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Verify the tool was selected // Verify the tool was selected
const isRectangleSelectedBefore = await page.locator('[data-testid="toolbar-rectangle"]').isChecked(); const isRectangleSelectedBefore = await page.locator('[data-testid="toolbar-rectangle"]').isChecked();
console.log("Rectangle tool selected before drawing:", isRectangleSelectedBefore); console.log("Rectangle tool selected before drawing:", isRectangleSelectedBefore);
// Draw the rectangle by dragging on the canvas - use center of canvas // Draw the rectangle by dragging on the canvas - use center of canvas
const centerX = box.x + box.width / 2; const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2; const centerY = box.y + box.height / 2;
@@ -192,13 +193,13 @@ test.describe("Drawing Editing", () => {
const startY = centerY - 75; const startY = centerY - 75;
const endX = centerX + 100; const endX = centerX + 100;
const endY = centerY + 75; const endY = centerY + 75;
console.log(`Drawing from (${startX}, ${startY}) to (${endX}, ${endY})`); console.log(`Drawing from (${startX}, ${startY}) to (${endX}, ${endY})`);
// First click on the canvas to ensure it has focus // First click on the canvas to ensure it has focus
await page.mouse.click(centerX, centerY); await page.mouse.click(centerX, centerY);
await page.waitForTimeout(200); await page.waitForTimeout(200);
// Now draw the rectangle // Now draw the rectangle
await page.mouse.move(startX, startY); await page.mouse.move(startX, startY);
await page.waitForTimeout(100); await page.waitForTimeout(100);
@@ -207,10 +208,10 @@ test.describe("Drawing Editing", () => {
await page.mouse.move(endX, endY, { steps: 20 }); await page.mouse.move(endX, endY, { steps: 20 });
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.up(); await page.mouse.up();
// Take a screenshot after drawing // Take a screenshot after drawing
await page.screenshot({ path: 'test-results/after-drawing.png' }); await page.screenshot({ path: 'test-results/after-drawing.png' });
// Check if Undo button is now enabled (indicating something was drawn) // Check if Undo button is now enabled (indicating something was drawn)
const undoButton = page.locator('button[aria-label="Undo"]'); const undoButton = page.locator('button[aria-label="Undo"]');
const isUndoDisabled = await undoButton.getAttribute('disabled'); const isUndoDisabled = await undoButton.getAttribute('disabled');
@@ -231,7 +232,7 @@ test.describe("Drawing Editing", () => {
}); });
test("should draw text on canvas", async ({ page, request }) => { test("should draw text on canvas", async ({ page, request }) => {
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `Draw_Text_${Date.now()}`, name: `Draw_Text_${Date.now()}`,
elements: [], elements: [],
}); });
@@ -245,11 +246,11 @@ test.describe("Drawing Editing", () => {
const canvas = page.locator("canvas.excalidraw__canvas.interactive"); const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox(); const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found"); if (!box) throw new Error("Canvas not found");
// Click to focus the canvas // Click to focus the canvas
await page.mouse.click(box.x + 100, box.y + 100); await page.mouse.click(box.x + 100, box.y + 100);
await page.waitForTimeout(100); await page.waitForTimeout(100);
// Select text tool using keyboard shortcut (now that canvas is focused) // Select text tool using keyboard shortcut (now that canvas is focused)
await page.keyboard.press("t"); await page.keyboard.press("t");
await page.waitForTimeout(200); await page.waitForTimeout(200);
@@ -260,7 +261,7 @@ test.describe("Drawing Editing", () => {
// Type some text // Type some text
await page.keyboard.type("Hello E2E Test"); await page.keyboard.type("Hello E2E Test");
// Press Escape to finish text editing // Press Escape to finish text editing
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.waitForTimeout(500); await page.waitForTimeout(500);
@@ -276,7 +277,7 @@ test.describe("Drawing Editing", () => {
}); });
test("should use undo/redo functionality", async ({ page, request }) => { test("should use undo/redo functionality", async ({ page, request }) => {
const drawing = await createDrawing(request, { const drawing = await createDrawing(request, {
name: `Undo_Redo_${Date.now()}`, name: `Undo_Redo_${Date.now()}`,
elements: [], elements: [],
}); });
@@ -290,10 +291,10 @@ test.describe("Drawing Editing", () => {
const canvas = page.locator("canvas.excalidraw__canvas.interactive"); const canvas = page.locator("canvas.excalidraw__canvas.interactive");
const box = await canvas.boundingBox(); const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found"); if (!box) throw new Error("Canvas not found");
await page.keyboard.press("r"); await page.keyboard.press("r");
await page.waitForTimeout(200); await page.waitForTimeout(200);
await page.mouse.move(box.x + 200, box.y + 200); await page.mouse.move(box.x + 200, box.y + 200);
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(box.x + 300, box.y + 300, { steps: 5 }); 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) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -341,7 +342,7 @@ test.describe("Drawing Deletion", () => {
// Find the card and select it // Find the card and select it
const card = page.locator(`#drawing-card-${drawing.id}`); const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover(); await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`); const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click(); await selectToggle.click();
@@ -360,9 +361,9 @@ test.describe("Drawing Deletion", () => {
}); });
test("should permanently delete drawing from trash", async ({ page, request }) => { 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()}`, name: `Perm_Delete_${Date.now()}`,
collectionId: "trash" collectionId: "trash"
}); });
createdDrawingIds.push(drawing.id); createdDrawingIds.push(drawing.id);
@@ -374,7 +375,7 @@ test.describe("Drawing Deletion", () => {
// Select the drawing // Select the drawing
const card = page.locator(`#drawing-card-${drawing.id}`); const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover(); await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`); const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click(); await selectToggle.click();
@@ -388,7 +389,7 @@ test.describe("Drawing Deletion", () => {
await expect(card).not.toBeVisible(); await expect(card).not.toBeVisible();
// Verify via API that drawing is deleted // 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); expect(response.status()).toBe(404);
// Remove from cleanup list since it's already deleted // Remove from cleanup list since it's already deleted
@@ -409,7 +410,7 @@ test.describe("Drawing Deletion", () => {
// Select the drawing // Select the drawing
const card = page.locator(`#drawing-card-${drawing.id}`); const card = page.locator(`#drawing-card-${drawing.id}`);
await card.hover(); await card.hover();
const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`); const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`);
await selectToggle.click(); await selectToggle.click();
@@ -422,7 +423,7 @@ test.describe("Drawing Deletion", () => {
// Clear search to see all drawings // Clear search to see all drawings
await page.getByPlaceholder("Search drawings...").fill(""); await page.getByPlaceholder("Search drawings...").fill("");
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Search again to find both // Search again to find both
await page.getByPlaceholder("Search drawings...").fill("Duplicate_Test"); await page.getByPlaceholder("Search drawings...").fill("Duplicate_Test");
await page.waitForTimeout(500); await page.waitForTimeout(500);
+35 -46
View File
@@ -1,12 +1,10 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
import { import {
API_URL, API_URL,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
getCsrfHeaders,
listDrawings, listDrawings,
createCollection,
deleteCollection, deleteCollection,
} from "./helpers/api"; } from "./helpers/api";
@@ -29,7 +27,7 @@ test.describe("Export Functionality", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -38,7 +36,7 @@ test.describe("Export Functionality", () => {
for (const id of createdCollectionIds) { for (const id of createdCollectionIds) {
try { try {
await deleteCollection(request, id); await deleteCollection(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -85,11 +83,11 @@ test.describe("Export Functionality", () => {
// Test JSON/ZIP export endpoint - it returns a ZIP file with .excalidraw files // Test JSON/ZIP export endpoint - it returns a ZIP file with .excalidraw files
const zipResponse = await request.get(`${API_URL}/export/json`); const zipResponse = await request.get(`${API_URL}/export/json`);
expect(zipResponse.ok()).toBe(true); expect(zipResponse.ok()).toBe(true);
// Check it's a ZIP file // Check it's a ZIP file
const contentType = zipResponse.headers()["content-type"]; const contentType = zipResponse.headers()["content-type"];
expect(contentType).toMatch(/application\/zip/); expect(contentType).toMatch(/application\/zip/);
// Check content-disposition header // Check content-disposition header
const contentDisposition = zipResponse.headers()["content-disposition"]; const contentDisposition = zipResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment"); expect(contentDisposition).toContain("attachment");
@@ -103,11 +101,11 @@ test.describe("Export Functionality", () => {
// Test SQLite export endpoint // Test SQLite export endpoint
const sqliteResponse = await request.get(`${API_URL}/export`); const sqliteResponse = await request.get(`${API_URL}/export`);
expect(sqliteResponse.ok()).toBe(true); expect(sqliteResponse.ok()).toBe(true);
// Check content-type header indicates a file download // Check content-type header indicates a file download
const contentType = sqliteResponse.headers()["content-type"]; const contentType = sqliteResponse.headers()["content-type"];
expect(contentType).toMatch(/application\/octet-stream|application\/x-sqlite3/); expect(contentType).toMatch(/application\/octet-stream|application\/x-sqlite3/);
// Check content-disposition header // Check content-disposition header
const contentDisposition = sqliteResponse.headers()["content-disposition"]; const contentDisposition = sqliteResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment"); expect(contentDisposition).toContain("attachment");
@@ -121,7 +119,7 @@ test.describe("Export Functionality", () => {
// Test .db export endpoint // Test .db export endpoint
const dbResponse = await request.get(`${API_URL}/export?format=db`); const dbResponse = await request.get(`${API_URL}/export?format=db`);
expect(dbResponse.ok()).toBe(true); expect(dbResponse.ok()).toBe(true);
const contentDisposition = dbResponse.headers()["content-disposition"]; const contentDisposition = dbResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment"); expect(contentDisposition).toContain("attachment");
expect(contentDisposition).toMatch(/\.db/); expect(contentDisposition).toMatch(/\.db/);
@@ -137,7 +135,7 @@ test.describe.serial("Import Functionality", () => {
for (const drawing of testDrawings) { for (const drawing of testDrawings) {
try { try {
await deleteDrawing(request, drawing.id); await deleteDrawing(request, drawing.id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -145,7 +143,7 @@ test.describe.serial("Import Functionality", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -161,7 +159,7 @@ test.describe.serial("Import Functionality", () => {
await expect(importButton).toBeVisible(); 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.goto("/");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
@@ -206,15 +204,14 @@ test.describe.serial("Import Functionality", () => {
}); });
// Write temp file // Write temp file
const tempDir = "/tmp"; // tempFile was here
const tempFile = `${tempDir}/Import_Test_${Date.now()}.excalidraw`;
// Use page.evaluate to check if we can proceed // Use page.evaluate to check if we can proceed
// Actually, Playwright has setInputFiles which can handle this // Actually, Playwright has setInputFiles which can handle this
// Find the import file input // Find the import file input
const fileInput = page.locator("#dashboard-import"); const fileInput = page.locator("#dashboard-import");
// Create a buffer from the fixture content // Create a buffer from the fixture content
await fileInput.setInputFiles({ await fileInput.setInputFiles({
name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`, name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`,
@@ -222,9 +219,8 @@ test.describe.serial("Import Functionality", () => {
buffer: Buffer.from(fixtureContent), buffer: Buffer.from(fixtureContent),
}); });
// Wait for success modal // Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "OK" }).click();
// Reload to ensure dashboard state reflects the newly imported drawing // Reload to ensure dashboard state reflects the newly imported drawing
await page.reload({ waitUntil: "networkidle" }); await page.reload({ waitUntil: "networkidle" });
@@ -237,13 +233,13 @@ test.describe.serial("Import Functionality", () => {
await expect(importedCards.first()).toBeVisible({ timeout: 10000 }); 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.goto("/");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const timestamp = Date.now(); const timestamp = Date.now();
const testName = `Import_JSONTest_${timestamp}`; const testName = `Import_JSONTest_${timestamp}`;
// Create a valid excalidraw JSON file with required fields // Create a valid excalidraw JSON file with required fields
const jsonContent = JSON.stringify({ const jsonContent = JSON.stringify({
type: "excalidraw", type: "excalidraw",
@@ -283,31 +279,22 @@ test.describe.serial("Import Functionality", () => {
}); });
const fileInput = page.locator("#dashboard-import"); const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles({ await fileInput.setInputFiles({
name: `${testName}.json`, name: `${testName}.json`,
mimeType: "application/json", mimeType: "application/json",
buffer: Buffer.from(jsonContent), buffer: Buffer.from(jsonContent),
}); });
// Wait for import result - could be success or failure // Wait for upload to complete - the UploadStatus component shows "Done" when finished
const successModal = page.getByText("Import Successful"); await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 15000 });
const failModal = page.getByText("Import Failed");
// Check if upload failed (shows "Failed" text in the upload status)
await expect(successModal.or(failModal)).toBeVisible({ timeout: 15000 }); const failedIndicator = page.getByText("Failed");
if (await failedIndicator.isVisible()) {
// If we got a failure, check the error console.log("Import failed - skipping rest of test");
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
return; return;
} }
await page.getByRole("button", { name: "OK" }).click();
// Reload to force a fresh fetch of drawings after import // Reload to force a fresh fetch of drawings after import
await page.reload({ waitUntil: "networkidle" }); 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 invalidContent = "this is not valid JSON or excalidraw format {}{}";
const fileInput = page.locator("#dashboard-import"); const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles({ await fileInput.setInputFiles({
name: `Import_Invalid_${Date.now()}.excalidraw`, name: `Import_Invalid_${Date.now()}.excalidraw`,
mimeType: "application/json", mimeType: "application/json",
buffer: Buffer.from(invalidContent), buffer: Buffer.from(invalidContent),
}); });
// Should show error modal // Wait for upload to complete and check for failure indicator
await expect(page.getByText("Import Failed")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "OK" }).click(); // Should show "Failed" status in the upload status component
await expect(page.getByText("Failed")).toBeVisible();
}); });
test("should import multiple drawings at once", async ({ page }) => { test("should import multiple drawings at once", async ({ page }) => {
@@ -377,8 +365,8 @@ test.describe.serial("Import Functionality", () => {
const fileInput = page.locator("#dashboard-import"); const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles(files); await fileInput.setInputFiles(files);
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 }); // Wait for upload to complete - the UploadStatus component shows "Done" when finished
await page.getByRole("button", { name: "OK" }).click(); await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Verify both were imported by searching for the unique prefix // Verify both were imported by searching for the unique prefix
await page.getByPlaceholder("Search drawings...").fill(searchPrefix); await page.getByPlaceholder("Search drawings...").fill(searchPrefix);
@@ -394,6 +382,7 @@ test.describe("Database Import Verification", () => {
// Test that the verification endpoint responds // Test that the verification endpoint responds
// We don't actually import a database as that would affect the test environment // We don't actually import a database as that would affect the test environment
const response = await request.post(`${API_URL}/import/sqlite/verify`, { const response = await request.post(`${API_URL}/import/sqlite/verify`, {
headers: await getCsrfHeaders(request),
// Send empty form data to test endpoint exists // Send empty form data to test endpoint exists
multipart: { multipart: {
db: { db: {
@@ -403,7 +392,7 @@ test.describe("Database Import Verification", () => {
}, },
}, },
}); });
// Should get an error response since the file is empty/invalid // Should get an error response since the file is empty/invalid
// But the endpoint should exist // But the endpoint should exist
expect([400, 500]).toContain(response.status()); expect([400, 500]).toContain(response.status());
+141 -6
View File
@@ -5,6 +5,91 @@ const DEFAULT_BACKEND_PORT = 8000;
export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`; 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 { export interface DrawingRecord {
id: string; id: string;
name: string; name: string;
@@ -53,10 +138,26 @@ export async function createDrawing(
overrides: CreateDrawingOptions = {} overrides: CreateDrawingOptions = {}
): Promise<DrawingRecord> { ): Promise<DrawingRecord> {
const payload = { ...defaultDrawingPayload(), ...overrides }; const payload = { ...defaultDrawingPayload(), ...overrides };
const response = await request.post(`${API_URL}/drawings`, { const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
headers: { "Content-Type": "application/json" },
let response = await request.post(`${API_URL}/drawings`, {
headers,
data: payload, 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()) { if (!response.ok()) {
const text = await response.text(); const text = await response.text();
throw new Error(`Failed to create drawing: ${response.status()} ${text}`); throw new Error(`Failed to create drawing: ${response.status()} ${text}`);
@@ -77,7 +178,17 @@ export async function deleteDrawing(
request: APIRequestContext, request: APIRequestContext,
id: string id: string
): Promise<void> { ): 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()) { if (!response.ok()) {
// Ignore not found to keep cleanup idempotent // Ignore not found to keep cleanup idempotent
if (response.status() !== 404) { if (response.status() !== 404) {
@@ -113,10 +224,24 @@ export async function createCollection(
request: APIRequestContext, request: APIRequestContext,
name: string name: string
): Promise<CollectionRecord> { ): Promise<CollectionRecord> {
const response = await request.post(`${API_URL}/collections`, { const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
headers: { "Content-Type": "application/json" },
let response = await request.post(`${API_URL}/collections`, {
headers,
data: { name }, 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); expect(response.ok()).toBe(true);
return (await response.json()) as CollectionRecord; return (await response.json()) as CollectionRecord;
} }
@@ -133,7 +258,17 @@ export async function deleteCollection(
request: APIRequestContext, request: APIRequestContext,
id: string id: string
): Promise<void> { ): 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.ok()) {
if (response.status() !== 404) { if (response.status() !== 404) {
const text = await response.text(); const text = await response.text();
+53 -41
View File
@@ -1,7 +1,13 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; 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 * 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", () => { test.describe("Image Persistence - Browser E2E Tests", () => {
let testDrawingIds: string[] = []; let testDrawingIds: string[] = [];
test.afterEach(async ({ request }) => { test.afterEach(async ({ request }) => {
// Clean up any drawings created during tests // Clean up any drawings created during tests
for (const id of testDrawingIds) { for (const id of testDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // 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 }) => { test("should navigate to dashboard and see drawing list", async ({ page }) => {
await page.goto("/"); await page.goto("/");
// Wait for the page to load // Wait for the page to load
await expect(page).toHaveTitle(/ExcaliDash/i); await expect(page).toHaveTitle(/ExcaliDash/i);
// The dashboard should show some UI elements // The dashboard should show some UI elements
await expect(page.locator("body")).toBeVisible(); await expect(page.locator("body")).toBeVisible();
}); });
test("should create a new drawing via UI", async ({ page }) => { test("should create a new drawing via UI", async ({ page }) => {
await page.goto("/"); await page.goto("/");
// Look for a "New Drawing" or similar button // Look for a "New Drawing" or similar button
const newDrawingBtn = page.getByRole("button", { name: /new|create/i }).first(); const newDrawingBtn = page.getByRole("button", { name: /new|create/i }).first();
if (await newDrawingBtn.isVisible()) { if (await newDrawingBtn.isVisible()) {
await newDrawingBtn.click(); await newDrawingBtn.click();
// Should navigate to editor or show a modal // Should navigate to editor or show a modal
await page.waitForURL(/\/(editor|drawing)/i, { timeout: 5000 }).catch(() => { await page.waitForURL(/\/(editor|drawing)/i, { timeout: 5000 }).catch(() => {
// May stay on same page with modal // 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 // This is the core regression test for issue #17
const largeDataUrl = generateLargeImageDataUrl(50000); const largeDataUrl = generateLargeImageDataUrl(50000);
expect(largeDataUrl.length).toBeGreaterThan(10000); expect(largeDataUrl.length).toBeGreaterThan(10000);
const files = { const files = {
"test-image-1": { "test-image-1": {
id: "test-image-1", id: "test-image-1",
@@ -80,23 +86,23 @@ test.describe("Image Persistence - Browser E2E Tests", () => {
created: Date.now(), created: Date.now(),
}, },
}; };
// Create drawing with large image // Create drawing with large image
const createdDrawing = await createDrawing(request, { const createdDrawing = await createDrawing(request, {
name: "E2E Test - Large Image", name: "E2E Test - Large Image",
files, files,
}); });
testDrawingIds.push(createdDrawing.id); testDrawingIds.push(createdDrawing.id);
// Retrieve the drawing // Retrieve the drawing
const drawing = await getDrawing(request, createdDrawing.id); const drawing = await getDrawing(request, createdDrawing.id);
const savedFiles = drawing.files || {}; // Already parsed by API const savedFiles = drawing.files || {}; // Already parsed by API
// Verify the image data was preserved // Verify the image data was preserved
expect(savedFiles["test-image-1"]).toBeDefined(); expect(savedFiles["test-image-1"]).toBeDefined();
expect(savedFiles["test-image-1"].dataURL).toBe(largeDataUrl); expect(savedFiles["test-image-1"].dataURL).toBe(largeDataUrl);
expect(savedFiles["test-image-1"].dataURL.length).toBe(largeDataUrl.length); expect(savedFiles["test-image-1"].dataURL.length).toBe(largeDataUrl.length);
console.log("✓ Large image data preserved correctly through save/reload cycle"); 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", name: "E2E Test - Editor View",
}); });
testDrawingIds.push(createdDrawing.id); testDrawingIds.push(createdDrawing.id);
// Navigate to the editor // Navigate to the editor
await page.goto(`/editor/${createdDrawing.id}`); await page.goto(`/editor/${createdDrawing.id}`);
// Wait for the page to load // Wait for the page to load
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// The editor should be visible (Excalidraw canvas) // The editor should be visible (Excalidraw canvas)
// Look for the Excalidraw container or canvas // Look for the Excalidraw container or canvas
const editorContainer = page.locator("[class*='excalidraw'], canvas").first(); const editorContainer = page.locator("[class*='excalidraw'], canvas").first();
await expect(editorContainer).toBeVisible({ timeout: 10000 }); 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 // Load the test fixture
const fixturePath = path.join(__dirname, "..", "fixtures", "small-image.excalidraw"); const fixturePath = path.join(__dirname, "..", "fixtures", "small-image.excalidraw");
const fixtureContent = fs.readFileSync(fixturePath, "utf-8"); const fixtureContent = fs.readFileSync(fixturePath, "utf-8");
const fixtureData = JSON.parse(fixtureContent); const fixtureData = JSON.parse(fixtureContent);
// Create drawing via API with fixture data // Create drawing via API with fixture data
const createdDrawing = await createDrawing(request, { const createdDrawing = await createDrawing(request, {
name: "E2E Test - Imported Image", name: "E2E Test - Imported Image",
files: fixtureData.files, files: fixtureData.files,
}); });
testDrawingIds.push(createdDrawing.id); testDrawingIds.push(createdDrawing.id);
// Verify via API that image data was preserved // 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 const savedFiles = drawing.files || {}; // Already parsed by API
expect(savedFiles["embedded-test-image"]).toBeDefined(); expect(savedFiles["embedded-test-image"]).toBeDefined();
expect(savedFiles["embedded-test-image"].dataURL).toBe(fixtureData.files["embedded-test-image"].dataURL); 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(), created: Date.now(),
}, },
}; };
const createdDrawing = await createDrawing(request, { const createdDrawing = await createDrawing(request, {
name: "E2E Test - Multiple Images", name: "E2E Test - Multiple Images",
files, files,
}); });
testDrawingIds.push(createdDrawing.id); testDrawingIds.push(createdDrawing.id);
const drawing = await getDrawing(request, createdDrawing.id); const drawing = await getDrawing(request, createdDrawing.id);
const savedFiles = drawing.files || {}; // Already parsed by API const savedFiles = drawing.files || {}; // Already parsed by API
// Verify all images preserved correctly // Verify all images preserved correctly
for (const [id, originalFile] of Object.entries(files)) { for (const [id, originalFile] of Object.entries(files)) {
expect(savedFiles[id]).toBeDefined(); expect(savedFiles[id]).toBeDefined();
expect(savedFiles[id].dataURL).toBe((originalFile as any).dataURL); expect(savedFiles[id].dataURL).toBe((originalFile as any).dataURL);
expect(savedFiles[id].dataURL.length).toBe((originalFile as any).dataURL.length); expect(savedFiles[id].dataURL.length).toBe((originalFile as any).dataURL.length);
} }
console.log("✓ Multiple images of varying sizes preserved correctly"); console.log("✓ Multiple images of varying sizes preserved correctly");
}); });
}); });
@@ -192,10 +198,11 @@ test.describe("Security - Malicious Content Blocking", () => {
created: Date.now(), created: Date.now(),
}, },
}; };
const response = await request.post(`${API_URL}/drawings`, { const response = await request.post(`${API_URL}/drawings`, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(await getCsrfHeaders(request)),
}, },
data: { data: {
name: "Security Test - JS URL", name: "Security Test - JS URL",
@@ -205,7 +212,7 @@ test.describe("Security - Malicious Content Blocking", () => {
preview: null, preview: null,
}, },
}); });
if (!response.ok()) { if (!response.ok()) {
const text = await response.text(); const text = await response.text();
console.error(`API Error: ${response.status()} - ${text}`); console.error(`API Error: ${response.status()} - ${text}`);
@@ -213,12 +220,14 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(response.ok()).toBe(true); expect(response.ok()).toBe(true);
const drawing = await response.json(); const drawing = await response.json();
const savedFiles = drawing.files; // Already parsed by API const savedFiles = drawing.files; // Already parsed by API
// The malicious URL should be blocked/cleared // The malicious URL should be blocked/cleared
expect(savedFiles["malicious-image"].dataURL).not.toContain("javascript:"); expect(savedFiles["malicious-image"].dataURL).not.toContain("javascript:");
// Cleanup // 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 }) => { test("should block script tags in image data", async ({ request }) => {
@@ -230,10 +239,11 @@ test.describe("Security - Malicious Content Blocking", () => {
created: Date.now(), created: Date.now(),
}, },
}; };
const response = await request.post(`${API_URL}/drawings`, { const response = await request.post(`${API_URL}/drawings`, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(await getCsrfHeaders(request)),
}, },
data: { data: {
name: "Security Test - Script Tag", name: "Security Test - Script Tag",
@@ -243,7 +253,7 @@ test.describe("Security - Malicious Content Blocking", () => {
preview: null, preview: null,
}, },
}); });
if (!response.ok()) { if (!response.ok()) {
const text = await response.text(); const text = await response.text();
console.error(`API Error: ${response.status()} - ${text}`); console.error(`API Error: ${response.status()} - ${text}`);
@@ -251,11 +261,13 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(response.ok()).toBe(true); expect(response.ok()).toBe(true);
const drawing = await response.json(); const drawing = await response.json();
const savedFiles = drawing.files; // Already parsed by API const savedFiles = drawing.files; // Already parsed by API
// The script tag should be blocked // The script tag should be blocked
expect(savedFiles["malicious-image"].dataURL).not.toContain("<script>"); expect(savedFiles["malicious-image"].dataURL).not.toContain("<script>");
// Cleanup // Cleanup
await request.delete(`${API_URL}/drawings/${drawing.id}`); await request.delete(`${API_URL}/drawings/${drawing.id}`, {
headers: await getCsrfHeaders(request),
});
}); });
}); });
+18 -19
View File
@@ -2,7 +2,6 @@ import { test, expect } from "@playwright/test";
import { import {
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
listDrawings,
} from "./helpers/api"; } from "./helpers/api";
/** /**
@@ -21,7 +20,7 @@ test.describe("Search Drawings", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -47,10 +46,10 @@ test.describe("Search Drawings", () => {
// Search for the prefix - should show only matching drawings // Search for the prefix - should show only matching drawings
await searchInput.fill(prefix); await searchInput.fill(prefix);
// Wait for search to apply (debounced) // Wait for search to apply (debounced)
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Verify only matching drawings are shown // Verify only matching drawings are shown
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible();
@@ -59,7 +58,7 @@ test.describe("Search Drawings", () => {
// Search for specific drawing // Search for specific drawing
await searchInput.fill(`${prefix}_Alpha`); await searchInput.fill(`${prefix}_Alpha`);
await page.waitForTimeout(500); await page.waitForTimeout(500);
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
await expect(page.locator(`#drawing-card-${drawing2.id}`)).not.toBeVisible(); await expect(page.locator(`#drawing-card-${drawing2.id}`)).not.toBeVisible();
}); });
@@ -92,7 +91,7 @@ test.describe("Search Drawings", () => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const searchInput = page.getByPlaceholder("Search drawings..."); const searchInput = page.getByPlaceholder("Search drawings...");
// Search for one drawing // Search for one drawing
await searchInput.fill(`${prefix}_One`); await searchInput.fill(`${prefix}_One`);
await page.waitForTimeout(500); await page.waitForTimeout(500);
@@ -105,7 +104,7 @@ test.describe("Search Drawings", () => {
// Search for prefix to find both // Search for prefix to find both
await searchInput.fill(prefix); await searchInput.fill(prefix);
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Both should be visible now // Both should be visible now
await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing1.id}`)).toBeVisible();
await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible(); await expect(page.locator(`#drawing-card-${drawing2.id}`)).toBeVisible();
@@ -119,10 +118,10 @@ test.describe("Search Drawings", () => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const searchInput = page.getByPlaceholder("Search drawings..."); const searchInput = page.getByPlaceholder("Search drawings...");
// Use keyboard shortcut (Cmd+K on Mac, Ctrl+K on Windows/Linux) // Use keyboard shortcut (Cmd+K on Mac, Ctrl+K on Windows/Linux)
await page.keyboard.press("Meta+k"); await page.keyboard.press("Meta+k");
// Search input should be focused // Search input should be focused
await expect(searchInput).toBeFocused(); await expect(searchInput).toBeFocused();
}); });
@@ -135,7 +134,7 @@ test.describe("Sort Drawings", () => {
for (const id of createdDrawingIds) { for (const id of createdDrawingIds) {
try { try {
await deleteDrawing(request, id); await deleteDrawing(request, id);
} catch (e) { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
@@ -144,7 +143,7 @@ test.describe("Sort Drawings", () => {
test("should sort drawings by name", async ({ page, request }) => { test("should sort drawings by name", async ({ page, request }) => {
const prefix = `SortTest_${Date.now()}`; const prefix = `SortTest_${Date.now()}`;
// Create drawings with names that sort in a specific order // Create drawings with names that sort in a specific order
const [drawingC, drawingA, drawingB] = await Promise.all([ const [drawingC, drawingA, drawingB] = await Promise.all([
createDrawing(request, { name: `${prefix}_Charlie` }), createDrawing(request, { name: `${prefix}_Charlie` }),
@@ -176,7 +175,7 @@ test.describe("Sort Drawings", () => {
test("should toggle sort direction on repeated clicks", async ({ page, request }) => { test("should toggle sort direction on repeated clicks", async ({ page, request }) => {
const prefix = `ToggleSortTest_${Date.now()}`; const prefix = `ToggleSortTest_${Date.now()}`;
const [drawingA, drawingZ] = await Promise.all([ const [drawingA, drawingZ] = await Promise.all([
createDrawing(request, { name: `${prefix}_AAA` }), createDrawing(request, { name: `${prefix}_AAA` }),
createDrawing(request, { name: `${prefix}_ZZZ` }), createDrawing(request, { name: `${prefix}_ZZZ` }),
@@ -191,11 +190,11 @@ test.describe("Sort Drawings", () => {
await page.waitForTimeout(500); await page.waitForTimeout(500);
const nameSortButton = page.getByRole("button", { name: "Name" }); const nameSortButton = page.getByRole("button", { name: "Name" });
// First click - ascending (A first) // First click - ascending (A first)
await nameSortButton.click(); await nameSortButton.click();
await page.waitForTimeout(200); await page.waitForTimeout(200);
let cards = page.locator("[id^='drawing-card-']"); let cards = page.locator("[id^='drawing-card-']");
let firstCard = cards.first(); let firstCard = cards.first();
await expect(firstCard).toHaveId(`drawing-card-${drawingA.id}`); await expect(firstCard).toHaveId(`drawing-card-${drawingA.id}`);
@@ -203,7 +202,7 @@ test.describe("Sort Drawings", () => {
// Second click - descending (Z first) // Second click - descending (Z first)
await nameSortButton.click(); await nameSortButton.click();
await page.waitForTimeout(200); await page.waitForTimeout(200);
cards = page.locator("[id^='drawing-card-']"); cards = page.locator("[id^='drawing-card-']");
firstCard = cards.first(); firstCard = cards.first();
await expect(firstCard).toHaveId(`drawing-card-${drawingZ.id}`); 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 }) => { test("should sort by date created", async ({ page, request }) => {
const prefix = `DateSortTest_${Date.now()}`; const prefix = `DateSortTest_${Date.now()}`;
// Create drawings sequentially to ensure different creation times // Create drawings sequentially to ensure different creation times
const drawing1 = await createDrawing(request, { name: `${prefix}_First` }); const drawing1 = await createDrawing(request, { name: `${prefix}_First` });
createdDrawingIds.push(drawing1.id); createdDrawingIds.push(drawing1.id);
await page.waitForTimeout(100); // Ensure different timestamps await page.waitForTimeout(100); // Ensure different timestamps
const drawing2 = await createDrawing(request, { name: `${prefix}_Second` }); const drawing2 = await createDrawing(request, { name: `${prefix}_Second` });
createdDrawingIds.push(drawing2.id); createdDrawingIds.push(drawing2.id);
@@ -241,7 +240,7 @@ test.describe("Sort Drawings", () => {
test("should sort by date modified", async ({ page, request }) => { test("should sort by date modified", async ({ page, request }) => {
const prefix = `ModifiedSortTest_${Date.now()}`; const prefix = `ModifiedSortTest_${Date.now()}`;
const [drawing1, drawing2] = await Promise.all([ const [drawing1, drawing2] = await Promise.all([
createDrawing(request, { name: `${prefix}_One` }), createDrawing(request, { name: `${prefix}_One` }),
createDrawing(request, { name: `${prefix}_Two` }), createDrawing(request, { name: `${prefix}_Two` }),
+15 -28
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.1.7", "version": "0.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.1.7", "version": "0.3.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -162,7 +162,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -517,7 +516,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -561,7 +559,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2612,7 +2609,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -2777,7 +2775,6 @@
"integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -2789,7 +2786,6 @@
"integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/react": "*" "@types/react": "*"
} }
@@ -2860,7 +2856,6 @@
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0", "@typescript-eslint/types": "8.47.0",
@@ -3224,7 +3219,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3275,6 +3269,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -3504,7 +3499,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001754",
@@ -3836,7 +3830,6 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10" "node": ">=0.10"
} }
@@ -4210,7 +4203,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -4450,7 +4442,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.1.6", "version": "3.1.6",
@@ -4669,7 +4662,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5503,7 +5495,6 @@
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz",
"integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12.20.0" "node": ">=12.20.0"
}, },
@@ -5790,9 +5781,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.17.21", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.debounce": { "node_modules/lodash.debounce": {
@@ -5851,6 +5842,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -6880,7 +6872,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
@@ -7046,6 +7037,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -7061,6 +7053,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -7116,7 +7109,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -7129,7 +7121,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -7143,7 +7134,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
@@ -7858,7 +7850,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -7997,7 +7988,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -8187,7 +8177,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -8281,7 +8270,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -8608,7 +8596,6 @@
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

Some files were not shown because too many files have changed in this diff Show More