diff --git a/scripts/install-ui-testing.sh b/scripts/install-ui-testing.sh new file mode 100755 index 0000000..b3280e4 --- /dev/null +++ b/scripts/install-ui-testing.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Script to install UI testing dependencies + +echo "Installing Node.js dependencies for UI testing..." +npm install + +echo "Installing Playwright browsers..." +npx playwright install + +echo "UI testing setup complete!" +echo "" +echo "You can now run UI tests with the following commands:" +echo " npm test - Run all tests" +echo " npm run test:headed - Run tests with visible browsers" +echo " npm run test:debug - Run tests in debug mode" +echo " npm run test:ui - Run tests with Playwright UI" +echo "" +echo "For more information, see tests/README.md" \ No newline at end of file diff --git a/tests/storage-providers.spec.js b/tests/storage-providers.spec.js new file mode 100644 index 0000000..f0d150f --- /dev/null +++ b/tests/storage-providers.spec.js @@ -0,0 +1,434 @@ +// @ts-check +import { test, expect } from '@playwright/test'; +import { login, createStorageProvider, cleanupCreatedProviders, testProviderConnection } from './test-setup.js'; + +/** + * This test logs in to the application and tests storage providers + */ +test.describe('Storage Providers', () => { + // Login before each test + test.beforeEach(async ({ page }) => { + await login( + page, + process.env.TEST_USERNAME || 'admin@example.com', + process.env.TEST_PASSWORD || 'admin' + ); + }); + + // Clean up created providers after tests + test.afterEach(async ({ page }) => { + await cleanupCreatedProviders(page); + }); + + // Test navigating to storage providers page + test('can navigate to storage providers page', async ({ page }) => { + // Navigate to storage providers page + await page.goto('/storage-providers'); + + // Verify the page title + await expect(page.locator('h1:has-text("Storage Providers")')).toBeVisible(); + }); + + // Test creating a new storage provider (SFTP) + test('can create a new SFTP storage provider', async ({ page }) => { + // Navigate to the storage providers page + await page.goto('/storage-providers'); + + // Click on "New Provider" button + await page.click('a:has-text("New Provider")'); + + // Verify we're on the provider creation form + await expect(page.locator('h1:has-text("New Storage Provider")')).toBeVisible(); + + // Fill in the form for SFTP provider + await page.fill('#name', 'Test SFTP Provider'); + await page.selectOption('#type', 'sftp'); + + // Wait for the SFTP fields to be visible + await expect(page.locator('#sftp-ftp-fields')).toBeVisible(); + + // Fill the SFTP form fields + await page.fill('#host', process.env.SFTP_HOST || 'sftp.example.com'); + await page.fill('#port', process.env.SFTP_PORT || '22'); + await page.fill('#username', process.env.SFTP_USERNAME || 'testuser'); + await page.fill('#password', process.env.SFTP_PASSWORD || 'testpassword'); + if (process.env.SFTP_KEY_FILE) { + await page.fill('#keyFile', process.env.SFTP_KEY_FILE); + } + + // Save the provider + await page.click('button:has-text("Save Provider")'); + + // Verify we're redirected back to the providers list + await expect(page.locator('h1:has-text("Storage Providers")')).toBeVisible(); + + // Verify our new provider is in the list - using more specific selector + await expect(page.locator('.text-blue-600.truncate:has-text("Test SFTP Provider")')).toBeVisible(); + + // Optionally test the connection if not using stubs + if (process.env.TEST_USE_STUBBED_PROVIDERS !== 'true') { + await testProviderConnection(page, 'Test SFTP Provider'); + } + }); + + // Test creating a new FTP storage provider + test('can create a new FTP storage provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test FTP Provider', + type: 'ftp', + host: process.env.FTP_HOST || 'ftp.example.com', + port: process.env.FTP_PORT || '21', + username: process.env.FTP_USERNAME || 'ftpuser', + password: process.env.FTP_PASSWORD || 'ftppassword' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Optionally test the connection if not using stubs + if (process.env.TEST_USE_STUBBED_PROVIDERS !== 'true') { + await testProviderConnection(page, providerName); + } + }); + + // Test creating a new Hetzner storage provider + test('can create a new Hetzner storage provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test Hetzner Provider', + type: 'hetzner', + host: process.env.HETZNER_HOST || 'u123456.your-storagebox.de', + port: process.env.HETZNER_PORT || '23', + username: process.env.HETZNER_USERNAME || 'u123456', + password: process.env.HETZNER_PASSWORD || 'hetznerpassword' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Optionally test the connection if not using stubs + if (process.env.TEST_USE_STUBBED_PROVIDERS !== 'true') { + await testProviderConnection(page, providerName); + } + }); + + // Test creating a new SMB/CIFS storage provider + test('can create a new SMB/CIFS storage provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test SMB Provider', + type: 'smb', + host: process.env.SMB_HOST || 'fileserver.example.com', + port: process.env.SMB_PORT || '445', + username: process.env.SMB_USERNAME || 'smbuser', + password: process.env.SMB_PASSWORD || 'smbpassword', + share: process.env.SMB_SHARE || 'Shared', + domain: process.env.SMB_DOMAIN || 'WORKGROUP' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Optionally test the connection if not using stubs + if (process.env.TEST_USE_STUBBED_PROVIDERS !== 'true') { + await testProviderConnection(page, providerName); + } + }); + + // Test creating a new S3 storage provider + test('can create a new S3 storage provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test S3 Provider', + type: 's3', + endpoint: process.env.S3_ENDPOINT || 's3.amazonaws.com', + region: process.env.S3_REGION || 'us-east-1', + bucket: process.env.S3_BUCKET || 'test-bucket', + accessKey: process.env.S3_ACCESS_KEY || 'AKIAIOSFODNN7EXAMPLE', + secretKey: process.env.S3_SECRET_KEY || 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Optionally test the connection if not using stubs + if (process.env.TEST_USE_STUBBED_PROVIDERS !== 'true') { + await testProviderConnection(page, providerName); + } + }); + + // Test creating a new Wasabi storage provider + test('can create a new Wasabi storage provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test Wasabi Provider', + type: 'wasabi', + endpoint: process.env.WASABI_ENDPOINT || 's3.wasabisys.com', + region: process.env.WASABI_REGION || 'us-east-1', + bucket: process.env.WASABI_BUCKET || 'wasabi-test-bucket', + accessKey: process.env.WASABI_ACCESS_KEY || 'WASABIEXAMPLEKEY', + secretKey: process.env.WASABI_SECRET_KEY || 'wasabiexamplesecretkey12345' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Optionally test the connection if not using stubs + if (process.env.TEST_USE_STUBBED_PROVIDERS !== 'true') { + await testProviderConnection(page, providerName); + } + }); + + // Test creating a new MinIO storage provider + test('can create a new MinIO storage provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test MinIO Provider', + type: 'minio', + endpoint: process.env.MINIO_ENDPOINT || 'play.min.io', + region: process.env.MINIO_REGION || 'us-east-1', + bucket: process.env.MINIO_BUCKET || 'minio-test-bucket', + accessKey: process.env.MINIO_ACCESS_KEY || 'Q3AM3UQ867SPQQA43P2F', + secretKey: process.env.MINIO_SECRET_KEY || 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Optionally test the connection if not using stubs + if (process.env.TEST_USE_STUBBED_PROVIDERS !== 'true') { + await testProviderConnection(page, providerName); + } + }); + + // Test creating a new Backblaze B2 storage provider + test('can create a new Backblaze B2 storage provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test B2 Provider', + type: 'b2', + endpoint: process.env.B2_ENDPOINT || 's3.us-west-002.backblazeb2.com', + region: process.env.B2_REGION || 'us-west-002', + bucket: process.env.B2_BUCKET || 'b2-test-bucket', + accessKey: process.env.B2_ACCESS_KEY || 'B2EXAMPLEKEYID', + secretKey: process.env.B2_SECRET_KEY || 'b2examplesecretkeyvalueforbackblazeb2' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Optionally test the connection if not using stubs + if (process.env.TEST_USE_STUBBED_PROVIDERS !== 'true') { + await testProviderConnection(page, providerName); + } + }); + + // Test creating a new WebDAV storage provider + test('can create a new WebDAV storage provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test WebDAV Provider', + type: 'webdav', + host: process.env.WEBDAV_HOST || 'webdav.example.com', + port: process.env.WEBDAV_PORT || '443', + username: process.env.WEBDAV_USERNAME || 'webdavuser', + password: process.env.WEBDAV_PASSWORD || 'webdavpassword' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Optionally test the connection if not using stubs + if (process.env.TEST_USE_STUBBED_PROVIDERS !== 'true') { + await testProviderConnection(page, providerName); + } + }); + + // Test creating a new Nextcloud storage provider + test('can create a new Nextcloud storage provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test Nextcloud Provider', + type: 'nextcloud', + host: process.env.NEXTCLOUD_HOST || 'nextcloud.example.com', + port: process.env.NEXTCLOUD_PORT || '443', + username: process.env.NEXTCLOUD_USERNAME || 'nextclouduser', + password: process.env.NEXTCLOUD_PASSWORD || 'nextcloudpassword' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Optionally test the connection if not using stubs + if (process.env.TEST_USE_STUBBED_PROVIDERS !== 'true') { + await testProviderConnection(page, providerName); + } + }); + + // Test creating a new Google Drive provider + test('can create a new Google Drive provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test Google Drive Provider', + type: 'gdrive', + clientID: process.env.GDRIVE_CLIENT_ID || '1234567890-abcdefghijklmnopqrstuvwxyz.apps.googleusercontent.com', + clientSecret: process.env.GDRIVE_CLIENT_SECRET || 'GOCSPX-abcdefghijklmnopqrstuvwxyz', + driveID: process.env.GDRIVE_DRIVE_ID, + teamDrive: process.env.GDRIVE_TEAM_DRIVE + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Note: We don't test connection for cloud providers as they require OAuth authentication + }); + + // Test creating a new Google Photos provider + test('can create a new Google Photos provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test Google Photos Provider', + type: 'gphotos', + clientID: process.env.GPHOTOS_CLIENT_ID || '1234567890-abcdefghijklmnopqrstuvwxyz.apps.googleusercontent.com', + clientSecret: process.env.GPHOTOS_CLIENT_SECRET || 'GOCSPX-abcdefghijklmnopqrstuvwxyz' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Note: We don't test connection for cloud providers as they require OAuth authentication + }); + + // Test creating a new OneDrive provider + test('can create a new OneDrive provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test OneDrive Provider', + type: 'onedrive', + clientID: process.env.ONEDRIVE_CLIENT_ID || '12345678-1234-1234-1234-123456789012', + clientSecret: process.env.ONEDRIVE_CLIENT_SECRET || 'abc~12345678901234567890abcdefghijklmn' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Note: We don't test connection for cloud providers as they require OAuth authentication + }); + + // Test creating a local file system provider + test('can create a new local filesystem provider', async ({ page }) => { + // Create a provider using the helper function + const providerName = await createStorageProvider(page, { + name: 'Test Local Provider', + type: 'local', + localPath: process.env.LOCAL_PATH || '/tmp/test-storage' + }); + + // Verify our new provider is in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Optionally test the connection if not using stubs + if (process.env.TEST_USE_STUBBED_PROVIDERS !== 'true') { + await testProviderConnection(page, providerName); + } + }); + + // Test editing a storage provider + test('can edit an existing storage provider', async ({ page }) => { + // Create a provider first + const providerName = await createStorageProvider(page, { + name: 'Provider To Edit', + type: 'sftp', + host: 'original.example.com', + port: '22', + username: 'original', + password: 'password' + }); + + // Find and click the edit button for this provider + const providerRow = page.locator(`li:has(.text-blue-600.truncate:has-text("${providerName}"))`); + await providerRow.locator('a:has-text("Edit")').click(); + + // Verify we're on the edit page + await expect(page.locator('h1:has-text("Edit Storage Provider")')).toBeVisible(); + + // Change the name and host + await page.fill('#name', 'Edited Provider'); + await page.fill('#host', 'edited.example.com'); + + // Save the changes + await page.click('button:has-text("Save Provider")'); + + // Verify we're back at the list + await expect(page.locator('h1:has-text("Storage Providers")')).toBeVisible(); + + // Verify the updated provider name is showing + await expect(page.locator('.text-blue-600.truncate:has-text("Edited Provider")')).toBeVisible(); + await expect(page.locator('li:has(.text-blue-600.truncate:has-text("Edited Provider"))').locator('text=edited.example.com')).toBeVisible(); + }); + + // Test testing a storage provider connection + test('can test a storage provider connection', async ({ page }) => { + // Create a provider to test + const providerName = await createStorageProvider(page, { + name: 'Provider To Test Connection', + type: 'local', + localPath: '/tmp/test-connection' + }); + + // Test the connection + await testProviderConnection(page, providerName); + + // Toast should be visible (we don't assert success because it depends on actual connection ability) + await expect(page.locator('.toast')).toBeVisible(); + }); + + // Test duplicating a storage provider + test('can duplicate a storage provider', async ({ page }) => { + // Create a provider to duplicate + const providerName = await createStorageProvider(page, { + name: 'Provider To Duplicate', + type: 'local', + localPath: '/tmp/duplicate-test' + }); + + // Navigate to storage providers + await page.goto('/storage-providers'); + + // Find the provider to duplicate + const providerRow = page.locator(`li:has(.text-blue-600.truncate:has-text("${providerName}"))`); + + // Click the duplicate button + await providerRow.locator('button:has-text("Duplicate")').click(); + + // There should now be a provider with the same name in the list more than once + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`).count()).toBeGreaterThan(1); + }); + + // Test deleting a storage provider + test('can delete a storage provider', async ({ page }) => { + // Create a provider to delete + const providerName = await createStorageProvider(page, { + name: 'Provider To Delete', + type: 'local', + localPath: '/tmp/delete-me' + }); + + // Navigate back to the providers list to refresh + await page.goto('/storage-providers'); + + // Verify the provider was created + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).toBeVisible(); + + // Find and click the delete button for this provider + const providerRow = page.locator(`li:has(.text-blue-600.truncate:has-text("${providerName}"))`); + await providerRow.locator('button:has-text("Delete"):not([hx-delete])').click(); + + // A confirmation dialog should appear - confirm deletion + await page.locator('button[hx-delete]:has-text("Delete"):visible').click(); + + // Verify the provider is no longer in the list + await expect(page.locator(`.text-blue-600.truncate:has-text("${providerName}")`)).not.toBeVisible(); + }); +}); \ No newline at end of file diff --git a/tests/test-setup.js b/tests/test-setup.js new file mode 100644 index 0000000..afe8855 --- /dev/null +++ b/tests/test-setup.js @@ -0,0 +1,222 @@ +// @ts-check +import { expect } from '@playwright/test'; +import dotenv from 'dotenv'; +import fs from 'fs'; +import path from 'path'; + +// Load environment variables from .env.test if it exists +const testEnvPath = path.join(process.cwd(), '.env.test'); +if (fs.existsSync(testEnvPath)) { + dotenv.config({ path: testEnvPath }); +} else { + // Fallback to regular .env + dotenv.config(); +} + +// Store providers created during the test so we can clean them up +const createdProviders = new Set(); + +/** + * Custom expect extension to wait for toast notifications + */ +expect.extend({ + async toShowToast(page, type, message) { + const toastSelector = type ? `.toast-${type}` : '.toast'; + + try { + await page.waitForSelector(toastSelector, { timeout: 5000 }); + + if (message) { + const toastContent = await page.locator(toastSelector).textContent(); + return { + pass: toastContent?.includes(message), + message: () => `Expected toast to contain "${message}" but found "${toastContent}"` + }; + } + + return { + pass: true, + message: () => `Toast of type ${type || 'any'} was found` + }; + } catch (e) { + return { + pass: false, + message: () => `Toast of type ${type || 'any'} was not found: ${e.message}` + }; + } + } +}); + +/** + * Helper to log in a user + * @param {import('@playwright/test').Page} page + * @param {string} username + * @param {string} password + */ +export async function login(page, username, password) { + // Navigate to the login page + await page.goto('/login'); + + // Check that we're on the login page + await expect(page.locator('h2:has-text("Sign In")')).toBeVisible(); + + // Fill in login form + await page.fill('#email', username); + await page.fill('#password', password); + + // Submit the form + await page.click('button:has-text("Sign in")'); + + // Wait for navigation to complete (dashboard should load) + await page.waitForURL('**/dashboard'); + + // Verify we're logged in by checking for user menu + await expect(page.locator('#user-menu-button')).toBeVisible(); +} + +/** + * Helper to create a storage provider for testing + * @param {import('@playwright/test').Page} page + * @param {Object} providerData + * @returns {Promise} The name of the created provider + */ +export async function createStorageProvider(page, providerData) { + // Navigate to the storage providers page + await page.goto('/storage-providers'); + + // Click on "New Provider" button + await page.click('a:has-text("New Provider")'); + + // Fill in the common fields + await page.fill('#name', providerData.name); + await page.selectOption('#type', providerData.type); + + // Fill in type-specific fields + switch (providerData.type) { + case 'sftp': + case 'ftp': + case 'hetzner': + await page.fill('#host', providerData.host || 'test.example.com'); + if (providerData.port) await page.fill('#port', providerData.port); + await page.fill('#username', providerData.username || 'testuser'); + await page.fill('#password', providerData.password || 'testpass'); + break; + + case 'smb': + await page.fill('#host', providerData.host || 'fileserver.example.com'); + if (providerData.port) await page.fill('#port', providerData.port); + await page.fill('#username', providerData.username || 'smbuser'); + await page.fill('#password', providerData.password || 'smbpassword'); + await page.fill('#share', providerData.share || 'Shared'); + if (providerData.domain) await page.fill('#domain', providerData.domain); + break; + + case 's3': + case 'wasabi': + case 'minio': + await page.fill('#endpoint', providerData.endpoint || 's3.example.com'); + await page.fill('#region', providerData.region || 'us-east-1'); + await page.fill('#bucket', providerData.bucket || 'test-bucket'); + await page.fill('#accessKey', providerData.accessKey || 'AKIATEST'); + await page.fill('#secretKey', providerData.secretKey || 'secretkey123'); + break; + + case 'gdrive': + case 'gphotos': + case 'onedrive': + await page.fill('#clientID', providerData.clientID || '123456789012-abcdef.apps.googleusercontent.com'); + await page.fill('#clientSecret', providerData.clientSecret || 'GOCSPX-abcdefghij'); + break; + + case 'local': + await page.fill('#localPath', providerData.localPath || '/tmp/test-path'); + break; + } + + // Save the provider + await page.click('button:has-text("Save Provider")'); + + // Wait to be redirected back to the list + await page.waitForURL('**/storage-providers*'); + + // Add to the list of created providers for cleanup + createdProviders.add(providerData.name); + + return providerData.name; +} + +/** + * Helper to delete a storage provider by name + * @param {import('@playwright/test').Page} page + * @param {string} providerName Name of the provider to delete + */ +export async function deleteStorageProvider(page, providerName) { + // Navigate to the storage providers page + await page.goto('/storage-providers'); + + // Look for the provider with the given name + const providerRow = page.locator(`li:has(.text-blue-600.truncate:has-text("${providerName}"))`); + + // Check if the provider exists + if (await providerRow.count() === 0) { + return; // Provider doesn't exist or was already deleted + } + + // Click the delete button - Using a more specific selector to get only the visible button with the trash icon + await providerRow.locator('button:has-text("Delete"):not([hx-delete])').click(); + + // Confirm deletion in the dialog - use a more specific selector for the confirmation button + await page.locator('button[hx-delete]:has-text("Delete"):visible').click(); + + // Wait for the toast notification + try { + await page.waitForSelector('.toast', { timeout: 5000 }); + } catch (e) { + // Continue even if toast doesn't appear + } + + // Remove from the set of created providers + createdProviders.delete(providerName); +} + +/** + * Cleanup all created providers + * @param {import('@playwright/test').Page} page + */ +export async function cleanupCreatedProviders(page) { + // Only attempt cleanup if there are providers to clean up + if (createdProviders.size === 0) return; + + // Navigate to the storage providers page + await page.goto('/storage-providers'); + + // Delete each created provider + for (const providerName of createdProviders) { + await deleteStorageProvider(page, providerName); + } + + // Clear the set + createdProviders.clear(); +} + +/** + * Helper to test a provider connection + * @param {import('@playwright/test').Page} page + * @param {string} providerName Name of the provider to test + */ +export async function testProviderConnection(page, providerName) { + // Navigate to the storage providers page + await page.goto('/storage-providers'); + + // Find the provider row + const providerRow = page.locator(`li:has(.text-blue-600.truncate:has-text("${providerName}"))`); + + // Click the test button + await providerRow.locator('button:has-text("Test")').click(); + + // Wait for toast notification + await page.waitForSelector('.toast', { timeout: 10000 }); + + // Return whether it was successful (toast-success) or not + return await page.locator('.toast-success').count() > 0; +} \ No newline at end of file