feat(storage): add drag & drop file moving and Playwright E2E tests

Drag & Drop:
- FileCard: draggable with type/id data transfer
- FolderCard: draggable + drop target with visual feedback (dashed green border)
- FileGrid: onMoveToFolder callback for drag-to-folder operations
- filesStore: moveFile() and moveFolder() methods via API
- Wired up in /files and /files/[folderId] pages with toast notifications

E2E Tests (Playwright):
- playwright.config.ts with multi-browser support
- auth.spec.ts: login page rendering, invalid credentials, redirect
- files.spec.ts: file list UI, view toggle, new folder modal, empty state
- navigation.spec.ts: nav items, routing, page headings
- search.spec.ts: search input, button state, initial state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-21 13:16:29 +01:00
parent a12cbeb8bb
commit 4bbe4a27d1
12 changed files with 534 additions and 6 deletions

View file

@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test';
// Auth tests run WITHOUT storageState (unauthenticated)
// Helper: wait for the app to finish loading (skeleton disappears)
async function waitForAppReady(page: import('@playwright/test').Page) {
// The root layout shows a loading spinner until auth initializes
// Wait for it to disappear and the actual page content to render
await page.waitForFunction(
() => {
// Check if loading skeleton is gone
const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]');
return !skeleton || skeleton.children.length === 0;
},
{ timeout: 30000 }
);
// Give Svelte time to render
await page.waitForTimeout(500);
}
test.describe('Authentication', () => {
test('login page renders with email and password fields', async ({ page }) => {
await page.goto('/login');
await waitForAppReady(page);
// LoginPage uses id="email" and id="password" (from shared-auth-ui)
const emailInput = page.locator('input[type="email"], input[name="email"], #email');
const passwordInput = page.locator('input[type="password"], input[name="password"], #password');
await expect(emailInput.first()).toBeVisible({ timeout: 10000 });
await expect(passwordInput.first()).toBeVisible({ timeout: 5000 });
await expect(page.locator('button[type="submit"]')).toBeVisible();
});
test('invalid credentials show error message', async ({ page }) => {
await page.goto('/login');
await waitForAppReady(page);
const emailInput = page.locator('input[type="email"], input[name="email"], #email').first();
const passwordInput = page
.locator('input[type="password"], input[name="password"], #password')
.first();
await emailInput.fill('nonexistent@test.local');
await passwordInput.fill('WrongPassword123!');
await page.locator('button[type="submit"]').click();
// Error alert should appear
const errorAlert = page.locator('#form-error, [role="alert"]');
await expect(errorAlert.first()).toBeVisible({ timeout: 10000 });
});
test('unauthenticated access to /files redirects to /login', async ({ page }) => {
await page.goto('/files');
// The app layout's onMount redirects unauthenticated users to /login
await page.waitForURL(/\/login/, { timeout: 30000 });
});
});

View file

@ -0,0 +1,119 @@
import { test, expect } from '@playwright/test';
// Helper: wait for the app to finish loading
async function waitForAppReady(page: import('@playwright/test').Page) {
await page.waitForFunction(
() => {
const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]');
return !skeleton || skeleton.children.length === 0;
},
{ timeout: 30000 }
);
await page.waitForTimeout(500);
}
test.describe('File Management', () => {
test('root page (/) redirects to /files', async ({ page }) => {
await page.goto('/');
// The root +page.svelte does goto('/files') on mount
await page.waitForURL(/\/files/, { timeout: 15000 });
});
test('files page shows "Meine Dateien" heading', async ({ page }) => {
await page.goto('/files');
await waitForAppReady(page);
const heading = page.locator('h1', { hasText: 'Meine Dateien' });
await expect(heading).toBeVisible({ timeout: 10000 });
});
test('files page has view toggle buttons (grid/list)', async ({ page }) => {
await page.goto('/files');
await waitForAppReady(page);
const gridButton = page.locator('button[aria-label="Rasteransicht"]');
const listButton = page.locator('button[aria-label="Listenansicht"]');
await expect(gridButton).toBeVisible({ timeout: 10000 });
await expect(listButton).toBeVisible();
});
test('files page has "Hochladen" and "Neuer Ordner" buttons', async ({ page }) => {
await page.goto('/files');
await waitForAppReady(page);
const uploadButton = page.locator('button', { hasText: 'Hochladen' });
const newFolderButton = page.locator('button', { hasText: 'Neuer Ordner' });
await expect(uploadButton).toBeVisible({ timeout: 10000 });
await expect(newFolderButton).toBeVisible();
});
test('clicking "Neuer Ordner" opens modal with name input', async ({ page }) => {
await page.goto('/files');
await waitForAppReady(page);
// Click the "Neuer Ordner" button
const newFolderButton = page.locator('button', { hasText: 'Neuer Ordner' }).first();
await newFolderButton.click();
// Modal should appear with role="dialog"
const modal = page.locator('[role="dialog"]');
await expect(modal).toBeVisible({ timeout: 5000 });
// Modal should have the title "Neuer Ordner"
const modalTitle = page.locator('#modal-title');
await expect(modalTitle).toHaveText('Neuer Ordner');
// Modal should have a folder name input
const nameInput = page.locator('#folder-name');
await expect(nameInput).toBeVisible();
await expect(nameInput).toHaveAttribute('type', 'text');
// Modal should have a submit button
const createButton = page.locator('[role="dialog"] button[type="submit"]');
await expect(createButton).toBeVisible();
await expect(createButton).toHaveText('Erstellen');
});
test('empty state shows upload prompt', async ({ page }) => {
await page.goto('/files');
await waitForAppReady(page);
// When no files exist, the empty state should show
// It may show loading first, then empty state or file list
// We check for either empty state or file content
const emptyState = page.locator('.empty-state');
const fileContent = page.locator('.files-page');
await expect(fileContent).toBeVisible({ timeout: 10000 });
// If empty state is visible, verify its content
if (await emptyState.isVisible()) {
const emptyHeading = page.locator('.empty-state h2', { hasText: 'Noch keine Dateien' });
await expect(emptyHeading).toBeVisible();
const emptyText = page.locator('.empty-state p', {
hasText: 'Lade deine ersten Dateien hoch',
});
await expect(emptyText).toBeVisible();
}
});
test('view toggle switches between grid and list view', async ({ page }) => {
await page.goto('/files');
await waitForAppReady(page);
const gridButton = page.locator('button[aria-label="Rasteransicht"]');
const listButton = page.locator('button[aria-label="Listenansicht"]');
// Click list view button
await listButton.click();
await expect(listButton).toHaveClass(/active/);
// Click grid view button
await gridButton.click();
await expect(gridButton).toHaveClass(/active/);
});
});

View file

@ -0,0 +1,98 @@
import { test, expect } from '@playwright/test';
// Helper: wait for the app to finish loading
async function waitForAppReady(page: import('@playwright/test').Page) {
await page.waitForFunction(
() => {
const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]');
return !skeleton || skeleton.children.length === 0;
},
{ timeout: 30000 }
);
await page.waitForTimeout(500);
}
test.describe('Navigation', () => {
test('all nav items exist: Dateien, Geteilt, Favoriten, Papierkorb, Suche', async ({ page }) => {
await page.goto('/files');
await waitForAppReady(page);
// The PillNavigation renders navigation links
const navItems = ['Dateien', 'Geteilt', 'Favoriten', 'Papierkorb', 'Suche'];
for (const label of navItems) {
const navLink = page.locator(`a, button`, { hasText: label }).first();
await expect(navLink).toBeVisible({ timeout: 10000 });
}
});
test('clicking navigation items changes URL', async ({ page }) => {
await page.goto('/files');
await waitForAppReady(page);
// Navigate to Favoriten
const favoritesLink = page.locator('a[href="/favorites"]').first();
await favoritesLink.click();
await expect(page).toHaveURL(/\/favorites/);
// Navigate to Papierkorb
const trashLink = page.locator('a[href="/trash"]').first();
await trashLink.click();
await expect(page).toHaveURL(/\/trash/);
// Navigate to Suche
const searchLink = page.locator('a[href="/search"]').first();
await searchLink.click();
await expect(page).toHaveURL(/\/search/);
// Navigate back to Dateien
const filesLink = page.locator('a[href="/files"]').first();
await filesLink.click();
await expect(page).toHaveURL(/\/files/);
});
test('search page has search input', async ({ page }) => {
await page.goto('/search');
await waitForAppReady(page);
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toBeVisible({ timeout: 10000 });
await expect(searchInput).toHaveAttribute('placeholder', 'Dateien und Ordner durchsuchen...');
});
test('trash page shows "Papierkorb" heading', async ({ page }) => {
await page.goto('/trash');
await waitForAppReady(page);
const heading = page.locator('h1', { hasText: 'Papierkorb' });
await expect(heading).toBeVisible({ timeout: 10000 });
});
test('settings page loads', async ({ page }) => {
await page.goto('/settings');
await waitForAppReady(page);
// The settings page has a title "Einstellungen"
const heading = page.getByText('Einstellungen', { exact: false }).first();
await expect(heading).toBeVisible({ timeout: 10000 });
});
test('offline page renders correctly', async ({ page }) => {
await page.goto('/offline');
// The offline page shows either "Du bist offline" or "Verbindung wiederhergestellt"
// Since we are connected during tests, it may redirect
// But we can check the page title
await expect(page).toHaveTitle(/Offline - Storage/);
// The page should contain the offline heading or redirect message
const offlineHeading = page.locator('h1');
await expect(offlineHeading).toBeVisible({ timeout: 10000 });
// Should show one of the expected texts
const headingText = await offlineHeading.textContent();
expect(
headingText === 'Du bist offline' || headingText === 'Verbindung wiederhergestellt!'
).toBeTruthy();
});
});

View file

@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
// Helper: wait for the app to finish loading
async function waitForAppReady(page: import('@playwright/test').Page) {
await page.waitForFunction(
() => {
const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]');
return !skeleton || skeleton.children.length === 0;
},
{ timeout: 30000 }
);
await page.waitForTimeout(500);
}
test.describe('Search', () => {
test('search page renders with input field', async ({ page }) => {
await page.goto('/search');
await waitForAppReady(page);
// Page heading
const heading = page.locator('h1', { hasText: 'Suche' });
await expect(heading).toBeVisible({ timeout: 10000 });
// Search input field
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toBeVisible();
});
test('search input has correct type="search"', async ({ page }) => {
await page.goto('/search');
await waitForAppReady(page);
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toBeVisible({ timeout: 10000 });
await expect(searchInput).toHaveAttribute('type', 'search');
await expect(searchInput).toHaveAttribute('aria-label', 'Dateien und Ordner durchsuchen');
});
test('empty search shows initial state message', async ({ page }) => {
await page.goto('/search');
await waitForAppReady(page);
// When no search has been performed, the initial state should show
const initialMessage = page.locator('.empty-state h2', {
hasText: 'Dateien durchsuchen',
});
await expect(initialMessage).toBeVisible({ timeout: 10000 });
const hintText = page.locator('.empty-state p', {
hasText: 'Gib einen Suchbegriff ein',
});
await expect(hintText).toBeVisible();
});
test('search button exists and is disabled when input is empty', async ({ page }) => {
await page.goto('/search');
await waitForAppReady(page);
// The search bar has a submit button
const searchButton = page.locator('.search-bar button', { hasText: 'Suchen' });
await expect(searchButton).toBeVisible({ timeout: 10000 });
// Button should be disabled when input is empty
await expect(searchButton).toBeDisabled();
// Type something into the search input
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('test query');
// Button should now be enabled
await expect(searchButton).toBeEnabled();
// Clear the input
await searchInput.fill('');
// Button should be disabled again
await expect(searchButton).toBeDisabled();
});
});

View file

@ -11,7 +11,9 @@
"lint": "eslint .",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@testing-library/svelte": "^5.2.0",
@ -36,6 +38,7 @@
},
"dependencies": {
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",

View file

@ -0,0 +1,39 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['html']],
use: {
baseURL: 'http://localhost:5185',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10000,
navigationTimeout: 30000,
},
timeout: 60000,
expect: { timeout: 5000 },
projects: process.env.CI
? [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
]
: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'pnpm run build && pnpm run preview --port 5185',
port: 5185,
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
outputDir: 'test-results/',
});

View file

@ -51,7 +51,17 @@
const Icon = getFileIcon(file.mimeType);
</script>
<div class="file-card" onclick={onClick} role="button" tabindex="0">
<div
class="file-card"
onclick={onClick}
role="button"
tabindex="0"
draggable="true"
ondragstart={(e) => {
e.dataTransfer?.setData('application/json', JSON.stringify({ type: 'file', id: file.id }));
e.dataTransfer!.effectAllowed = 'move';
}}
>
<div class="file-icon">
<Icon size={40} strokeWidth={1.5} />
{#if file.isFavorite}

View file

@ -10,10 +10,18 @@
onFolderClick?: (folder: StorageFolder) => void;
onFileAction?: (action: string, file: StorageFile) => void;
onFolderAction?: (action: string, folder: StorageFolder) => void;
onMoveToFolder?: (itemType: 'file' | 'folder', itemId: string, targetFolderId: string) => void;
}
let { files, folders, onFileClick, onFolderClick, onFileAction, onFolderAction }: Props =
$props();
let {
files,
folders,
onFileClick,
onFolderClick,
onFileAction,
onFolderAction,
onMoveToFolder,
}: Props = $props();
</script>
<div class="file-grid">
@ -22,6 +30,7 @@
{folder}
onClick={() => onFolderClick?.(folder)}
onAction={(action) => onFolderAction?.(action, folder)}
onDrop={(data) => onMoveToFolder?.(data.type, data.id, folder.id)}
/>
{/each}
{#each files as file (file.id)}

View file

@ -6,11 +6,40 @@
folder: StorageFolder;
onClick?: () => void;
onAction?: (action: string) => void;
onDrop?: (data: { type: 'file' | 'folder'; id: string }) => void;
}
let { folder, onClick, onAction }: Props = $props();
let { folder, onClick, onAction, onDrop }: Props = $props();
let showMenu = $state(false);
let isDragOver = $state(false);
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
isDragOver = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
isDragOver = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
isDragOver = false;
const raw = e.dataTransfer?.getData('application/json');
if (raw) {
try {
const data = JSON.parse(raw);
if (data.id !== folder.id) {
onDrop?.(data);
}
} catch {}
}
}
function handleMenuClick(e: MouseEvent) {
e.stopPropagation();
@ -37,7 +66,21 @@
let folderColor = $derived(folder.color ? colorMap[folder.color] || folder.color : undefined);
</script>
<div class="folder-card" onclick={onClick} role="button" tabindex="0">
<div
class="folder-card"
class:drag-over={isDragOver}
onclick={onClick}
role="button"
tabindex="0"
draggable="true"
ondragstart={(e) => {
e.dataTransfer?.setData('application/json', JSON.stringify({ type: 'folder', id: folder.id }));
e.dataTransfer!.effectAllowed = 'move';
}}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
<div class="folder-icon" style:color={folderColor}>
<Folder size={40} strokeWidth={1.5} fill="currentColor" />
{#if folder.isFavorite}
@ -94,6 +137,13 @@
box-shadow: var(--shadow-md);
}
.folder-card.drag-over {
border-color: rgb(var(--color-success));
border-style: dashed;
background: rgba(var(--color-success), 0.05);
box-shadow: var(--shadow-lg);
}
.folder-icon {
position: relative;
color: rgb(var(--color-primary));

View file

@ -155,6 +155,22 @@ export const filesStore = {
return result;
},
async moveFile(id: string, targetFolderId: string) {
const result = await filesApi.move(id, targetFolderId);
if (!result.error) {
files = files.filter((f) => f.id !== id);
}
return result;
},
async moveFolder(id: string, targetFolderId: string) {
const result = await foldersApi.move(id, targetFolderId);
if (!result.error) {
folders = folders.filter((f) => f.id !== id);
}
return result;
},
async downloadFile(id: string, filename: string) {
const blob = await filesApi.download(id);
if (blob) {

View file

@ -151,6 +151,28 @@
}
}
async function handleMoveToFolder(
itemType: 'file' | 'folder',
itemId: string,
targetFolderId: string
) {
if (itemType === 'file') {
const result = await filesStore.moveFile(itemId, targetFolderId);
if (result?.error) {
toastStore.error(result.error);
} else {
toastStore.success('Datei verschoben');
}
} else {
const result = await filesStore.moveFolder(itemId, targetFolderId);
if (result?.error) {
toastStore.error(result.error);
} else {
toastStore.success('Ordner verschoben');
}
}
}
function handleBreadcrumbNavigate(id: string | null) {
if (id) {
goto(`/files/${id}`);
@ -245,6 +267,7 @@
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
onFolderAction={handleFolderAction}
onMoveToFolder={handleMoveToFolder}
/>
{:else}
<FileList

View file

@ -165,6 +165,28 @@
}
}
async function handleMoveToFolder(
itemType: 'file' | 'folder',
itemId: string,
targetFolderId: string
) {
if (itemType === 'file') {
const result = await filesStore.moveFile(itemId, targetFolderId);
if (result?.error) {
toastStore.error(result.error);
} else {
toastStore.success('Datei verschoben');
}
} else {
const result = await filesStore.moveFolder(itemId, targetFolderId);
if (result?.error) {
toastStore.error(result.error);
} else {
toastStore.success('Ordner verschoben');
}
}
}
function goBack() {
const parentId = filesStore.currentFolder?.parentFolderId;
if (parentId) {
@ -261,6 +283,7 @@
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
onFolderAction={handleFolderAction}
onMoveToFolder={handleMoveToFolder}
/>
{:else}
<FileList