feat: Add UI testing setup and storage provider tests

- Introduced a new script for installing UI testing dependencies, including Node.js and Playwright browsers.
- Added comprehensive tests for managing storage providers, covering creation, editing, deletion, and connection testing.
- Implemented helper functions for user login and provider management in the test setup.
- Enhanced test coverage for various storage provider types, including SFTP, FTP, SMB, S3, and cloud services.
This commit is contained in:
StarFleetCPTN
2025-04-16 19:25:41 -07:00
parent 130a3b0d1c
commit 776c4971a6
3 changed files with 675 additions and 0 deletions
+19
View File
@@ -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"
+434
View File
@@ -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();
});
});
+222
View File
@@ -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<string>} 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;
}