Files
ExcaliDash/e2e/tests/export-import.spec.ts
T
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

401 lines
13 KiB
TypeScript

import { test, expect } from "@playwright/test";
import {
API_URL,
createDrawing,
deleteDrawing,
getCsrfHeaders,
listDrawings,
deleteCollection,
} from "./helpers/api";
/**
* E2E Tests for Export/Import functionality
*
* Tests the export/import feature mentioned in README:
* - Export drawings as JSON
* - Export database backup (SQLite)
* - Import .excalidraw files
* - Import JSON files
* - Import database backup
*/
test.describe("Export Functionality", () => {
let createdDrawingIds: string[] = [];
let createdCollectionIds: string[] = [];
test.afterEach(async ({ request }) => {
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch {
// Ignore cleanup errors
}
}
createdDrawingIds = [];
for (const id of createdCollectionIds) {
try {
await deleteCollection(request, id);
} catch {
// Ignore cleanup errors
}
}
createdCollectionIds = [];
});
test("should export database as SQLite via Settings page", async ({ page, request }) => {
// Create a drawing to ensure there's data to export
const drawing = await createDrawing(request, { name: `Export_SQLite_${Date.now()}` });
createdDrawingIds.push(drawing.id);
// Navigate to Settings
await page.goto("/settings");
await page.waitForLoadState("networkidle");
// Find and verify the export button exists
const exportSqliteButton = page.getByRole("button", { name: /Export Data \(.sqlite\)/i });
await expect(exportSqliteButton).toBeVisible();
// Verify the button links to the correct endpoint
// We can't easily test the actual download, but we can verify the UI
const exportDbButton = page.getByRole("button", { name: /Export Data \(.db\)/i });
await expect(exportDbButton).toBeVisible();
});
test("should export database as JSON via Settings page", async ({ page, request }) => {
// Create test data
const drawing = await createDrawing(request, { name: `Export_JSON_${Date.now()}` });
createdDrawingIds.push(drawing.id);
await page.goto("/settings");
await page.waitForLoadState("networkidle");
// Find the JSON export button
const exportJsonButton = page.getByRole("button", { name: /Export Data \(JSON\)/i });
await expect(exportJsonButton).toBeVisible();
});
test("should have export endpoints accessible via API", async ({ request }) => {
// Create test data
const drawing = await createDrawing(request, { name: `Export_API_${Date.now()}` });
createdDrawingIds.push(drawing.id);
// Test JSON/ZIP export endpoint - it returns a ZIP file with .excalidraw files
const zipResponse = await request.get(`${API_URL}/export/json`);
expect(zipResponse.ok()).toBe(true);
// Check it's a ZIP file
const contentType = zipResponse.headers()["content-type"];
expect(contentType).toMatch(/application\/zip/);
// Check content-disposition header
const contentDisposition = zipResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment");
expect(contentDisposition).toMatch(/excalidraw-drawings.*\.zip/);
});
test("should download SQLite export via API", async ({ request }) => {
const drawing = await createDrawing(request, { name: `SQLite_Export_${Date.now()}` });
createdDrawingIds.push(drawing.id);
// Test SQLite export endpoint
const sqliteResponse = await request.get(`${API_URL}/export`);
expect(sqliteResponse.ok()).toBe(true);
// Check content-type header indicates a file download
const contentType = sqliteResponse.headers()["content-type"];
expect(contentType).toMatch(/application\/octet-stream|application\/x-sqlite3/);
// Check content-disposition header
const contentDisposition = sqliteResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment");
expect(contentDisposition).toMatch(/excalidash-db.*\.sqlite/);
});
test("should download .db export via API", async ({ request }) => {
const drawing = await createDrawing(request, { name: `DB_Export_${Date.now()}` });
createdDrawingIds.push(drawing.id);
// Test .db export endpoint
const dbResponse = await request.get(`${API_URL}/export?format=db`);
expect(dbResponse.ok()).toBe(true);
const contentDisposition = dbResponse.headers()["content-disposition"];
expect(contentDisposition).toContain("attachment");
expect(contentDisposition).toMatch(/\.db/);
});
});
test.describe.serial("Import Functionality", () => {
let createdDrawingIds: string[] = [];
test.afterEach(async ({ request }) => {
// Clean up any drawings created via import
const testDrawings = await listDrawings(request, { search: "Import_" });
for (const drawing of testDrawings) {
try {
await deleteDrawing(request, drawing.id);
} catch {
// Ignore cleanup errors
}
}
for (const id of createdDrawingIds) {
try {
await deleteDrawing(request, id);
} catch {
// Ignore cleanup errors
}
}
createdDrawingIds = [];
});
test("should show Import Data button on Settings page", async ({ page }) => {
await page.goto("/settings");
await page.waitForLoadState("networkidle");
// Find the import button
const importButton = page.getByRole("button", { name: /Import Data/i });
await expect(importButton).toBeVisible();
});
test("should import .excalidraw file from Dashboard", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Create fixture content
const fixtureContent = JSON.stringify({
type: "excalidraw",
version: 2,
source: "e2e-test",
elements: [
{
id: "test-rect-1",
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
angle: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "solid",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 12345,
version: 1,
versionNonce: 67890,
isDeleted: false,
boundElements: null,
updated: Date.now(),
link: null,
locked: false,
}
],
appState: {
viewBackgroundColor: "#ffffff"
},
files: {}
});
// Write temp file
// tempFile was here
// Use page.evaluate to check if we can proceed
// Actually, Playwright has setInputFiles which can handle this
// Find the import file input
const fileInput = page.locator("#dashboard-import");
// Create a buffer from the fixture content
await fileInput.setInputFiles({
name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`,
mimeType: "application/json",
buffer: Buffer.from(fixtureContent),
});
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Reload to ensure dashboard state reflects the newly imported drawing
await page.reload({ waitUntil: "networkidle" });
// Verify the drawing was imported - the drawing name is the filename without extension
await page.getByPlaceholder("Search drawings...").fill("Import_ExcalidrawTest");
await page.waitForTimeout(1000);
const importedCards = page.locator("[id^='drawing-card-']");
await expect(importedCards.first()).toBeVisible({ timeout: 10000 });
});
test("should import JSON drawing file from Dashboard", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
const timestamp = Date.now();
const testName = `Import_JSONTest_${timestamp}`;
// Create a valid excalidraw JSON file with required fields
const jsonContent = JSON.stringify({
type: "excalidraw",
version: 2,
source: "e2e-test",
elements: [
{
id: "test-element",
type: "rectangle",
x: 100,
y: 100,
width: 100,
height: 100,
angle: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "solid",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: null,
seed: 12345,
version: 1,
versionNonce: 12345,
isDeleted: false,
boundElements: null,
updated: Date.now(),
link: null,
locked: false,
}
],
appState: { viewBackgroundColor: "#ffffff" },
files: {}
});
const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles({
name: `${testName}.json`,
mimeType: "application/json",
buffer: Buffer.from(jsonContent),
});
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 15000 });
// Check if upload failed (shows "Failed" text in the upload status)
const failedIndicator = page.getByText("Failed");
if (await failedIndicator.isVisible()) {
console.log("Import failed - skipping rest of test");
return;
}
// Reload to force a fresh fetch of drawings after import
await page.reload({ waitUntil: "networkidle" });
// Clear any existing search and search for the imported drawing
const searchInput = page.getByPlaceholder("Search drawings...");
await searchInput.clear();
await searchInput.fill(testName);
await page.waitForTimeout(1500);
// Wait for the card to appear - the drawing should be visible in the UI
const importedCards = page.locator("[id^='drawing-card-']");
await expect(importedCards.first()).toBeVisible({ timeout: 15000 });
});
test("should show error for invalid import file", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Create an invalid file
const invalidContent = "this is not valid JSON or excalidraw format {}{}";
const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles({
name: `Import_Invalid_${Date.now()}.excalidraw`,
mimeType: "application/json",
buffer: Buffer.from(invalidContent),
});
// Wait for upload to complete and check for failure indicator
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Should show "Failed" status in the upload status component
await expect(page.getByText("Failed")).toBeVisible();
});
test("should import multiple drawings at once", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
const timestamp = Date.now();
const searchPrefix = `Import_Multi_${timestamp}`;
const files = [
{
name: `${searchPrefix}_A.excalidraw`,
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify({
type: "excalidraw",
version: 2,
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {}
})),
},
{
name: `${searchPrefix}_B.excalidraw`,
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify({
type: "excalidraw",
version: 2,
elements: [],
appState: { viewBackgroundColor: "#f0f0f0" },
files: {}
})),
},
];
const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles(files);
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Verify both were imported by searching for the unique prefix
await page.getByPlaceholder("Search drawings...").fill(searchPrefix);
await page.waitForTimeout(500);
const importedCards = page.locator("[id^='drawing-card-']");
await expect(importedCards).toHaveCount(2);
});
});
test.describe("Database Import Verification", () => {
test("should verify SQLite import endpoint exists", async ({ request }) => {
// Test that the verification endpoint responds
// We don't actually import a database as that would affect the test environment
const response = await request.post(`${API_URL}/import/sqlite/verify`, {
headers: await getCsrfHeaders(request),
// Send empty form data to test endpoint exists
multipart: {
db: {
name: "test.sqlite",
mimeType: "application/x-sqlite3",
buffer: Buffer.from(""),
},
},
});
// Should get an error response since the file is empty/invalid
// But the endpoint should exist
expect([400, 500]).toContain(response.status());
});
});