diff --git a/apps/storage/apps/web/e2e/auth.spec.ts b/apps/storage/apps/web/e2e/auth.spec.ts new file mode 100644 index 000000000..1e05344af --- /dev/null +++ b/apps/storage/apps/web/e2e/auth.spec.ts @@ -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 }); + }); +}); diff --git a/apps/storage/apps/web/e2e/files.spec.ts b/apps/storage/apps/web/e2e/files.spec.ts new file mode 100644 index 000000000..9e4355b01 --- /dev/null +++ b/apps/storage/apps/web/e2e/files.spec.ts @@ -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/); + }); +}); diff --git a/apps/storage/apps/web/e2e/navigation.spec.ts b/apps/storage/apps/web/e2e/navigation.spec.ts new file mode 100644 index 000000000..316a8ee50 --- /dev/null +++ b/apps/storage/apps/web/e2e/navigation.spec.ts @@ -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(); + }); +}); diff --git a/apps/storage/apps/web/e2e/search.spec.ts b/apps/storage/apps/web/e2e/search.spec.ts new file mode 100644 index 000000000..03af770e2 --- /dev/null +++ b/apps/storage/apps/web/e2e/search.spec.ts @@ -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(); + }); +}); diff --git a/apps/storage/apps/web/package.json b/apps/storage/apps/web/package.json index bbe3cf5a6..31afb18aa 100644 --- a/apps/storage/apps/web/package.json +++ b/apps/storage/apps/web/package.json @@ -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:*", diff --git a/apps/storage/apps/web/playwright.config.ts b/apps/storage/apps/web/playwright.config.ts new file mode 100644 index 000000000..fbce03f7b --- /dev/null +++ b/apps/storage/apps/web/playwright.config.ts @@ -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/', +}); diff --git a/apps/storage/apps/web/src/lib/components/files/FileCard.svelte b/apps/storage/apps/web/src/lib/components/files/FileCard.svelte index 6ef0f7809..a1359cfc4 100644 --- a/apps/storage/apps/web/src/lib/components/files/FileCard.svelte +++ b/apps/storage/apps/web/src/lib/components/files/FileCard.svelte @@ -51,7 +51,17 @@ const Icon = getFileIcon(file.mimeType); -