diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 0000000000..3d08fa55e1 --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,17 @@ +# Unraid Server Configuration +UNRAID_URL=http://tower.local + +# Authentication (if needed) +UNRAID_USERNAME=root +UNRAID_PASSWORD= + +# Test Configuration +TEST_TIMEOUT=60000 +RETRY_COUNT=2 + +# Browser Configuration +SLOW_MO=0 + +# Debugging +DEBUG=false +TRACE=false \ No newline at end of file diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000000..e7f7e9754d --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +/test-results*/ +/playwright-report*/ +/blob-report*/ +/playwright/.cache/ +.env* +!.env.example +*.log +screenshots/ +videos/ +traces/ + +# specific to each individual developer, not the project +auth.json \ No newline at end of file diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000000..505664f054 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,272 @@ +# Unraid WebGUI E2E Tests + +End-to-end tests for Unraid WebGUI using Playwright. + +## Setup + +1. Copy `.env.example` to `.env` and configure: + ```bash + cp .env.example .env + ``` + +2. Install dependencies: + ```bash + pnpm install + pnpm playwright:install + ``` + +3. Configure your Unraid server URL in `.env`: + ``` + UNRAID_URL=http://tower.local + UNRAID_USERNAME=root + UNRAID_PASSWORD=your_password + ``` + +## Creating Test Profiles + +To test against multiple Unraid servers or configurations, create additional environment files: + +1. Copy `.env.example` to create new profiles: + ```bash + cp .env.example .env.test-server + cp .env.example .env.dev-server-2 + cp .env.example .env.staging + ``` + +2. Configure each profile with different server details: + ```bash + # .env.test-server + UNRAID_URL=https://test.example.com + UNRAID_USERNAME=root + UNRAID_PASSWORD=test_password + + # .env.dev-server-2 + UNRAID_URL=http://192.168.1.50 + UNRAID_USERNAME=admin + UNRAID_PASSWORD=dev_password + ``` + +You can then run tests against specific profiles using dotenvx or the justfile recipes (see below). + +## Running Tests + +By default, `pnpm test` commands will read environment variables from `.env`. + +Use `dotenvx` as shown below to override. + +```bash +# Run all tests +pnpm test + +# Run tests in headed mode (see browser) +pnpm test:headed + +# Debug tests interactively +pnpm test:debug + +# Open Playwright UI +pnpm test:ui + +# Run specific test file +pnpm test auth.spec.ts + +# Run tests against different server +UNRAID_URL=http://192.168.1.100 pnpm test:e2e + +# Run tests against different "profiles" +# -> Make sure not to track your profiles in git +dotenvx run -f .env.dev-server-2 -- pnpm test +``` + +## Justfile Usage + +This project includes a `justfile` for advanced testing workflows. Install [just](https://github.com/casey/just) if you haven't already. + +### Quick Start + +```bash +# Install dependencies (including dotenvx if not present) +just install + +# List available commands +just + +# List your environment profiles +just list-envs +``` + +### Running Tests with Profiles + +```bash +# Run tests against a specific environment file +just test-env .env.production-server + +# Run tests against a specific environment with additional args +just test-env .env.dev-server-2 --grep "login" + +# Run tests against ALL environment files (excludes .env.example) +just test-all-envs + +# Run tests against all environments with specific test pattern +just test-all-envs --grep "dashboard" +``` + +### Headed Mode Testing + +```bash +# Run tests in headed mode for debugging +just test-env-headed .env.staging + +# Run headed tests with specific browser +just test-env-headed .env.local --project=chromium +``` + +### Managing Results + +```bash +# View report for a specific environment +just show-report production-server + +# Clean all test artifacts and reports +just clean +``` + +### Use Cases + +- **Multi-environment validation**: Test the same suite against development, staging, and production servers +- **Configuration testing**: Validate behavior across different Unraid configurations (different plugins, versions, etc.) +- **Regression testing**: Run focused tests against specific environments when debugging issues +- **Batch testing**: Validate changes across your entire server fleet with one command + +The justfile automatically organizes reports by environment (e.g., `playwright-report-production-server`) so you can easily compare results across different servers. + +## Test Structure + +``` +e2e/ +├── fixtures/ # Test fixtures and setup +├── tests/ # Test specifications +├── utils/ # Helper functions and page objects +│ ├── pages/ # Page object models +│ └── helpers.ts # Utility functions +└── playwright.config.ts +``` + +## Writing Tests + +### Basic Test +```typescript +import { test, expect } from '@playwright/test'; + +test('should load dashboard', async ({ page }) => { + await page.goto('/Dashboard'); + await expect(page.locator('h1')).toContainText('Dashboard'); +}); +``` + +### With Authentication +```typescript +import { test, expect } from '../fixtures/auth.fixture'; + +test('authenticated test', async ({ page, authenticatedPage }) => { + // Already logged in + await page.goto('/Settings'); + // ... test code +}); +``` + +### Page Objects +```typescript +import { DashboardPage } from '../utils/pages/dashboard.page'; + +test('using page object', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.goto(); + await dashboard.navigateTo('Docker'); +}); +``` + +## CI/CD Integration + +Tests can run in CI with: +```yaml +- name: Run E2E Tests + env: + UNRAID_URL: ${{ secrets.UNRAID_URL }} + UNRAID_PASSWORD: ${{ secrets.UNRAID_PASSWORD }} + run: pnpm test:e2e +``` + +## Logging + +Tests automatically capture console logs and redirect them to structured log files. You don't need to import anything - just use standard console methods: + +```typescript +test('example test', async ({ page }) => { + console.log('Starting test'); + console.info('User navigated to dashboard'); + console.warn('Slow network detected'); + console.error('Authentication failed'); + + // All logs are automatically captured with test context +}); +``` + +### Log Files Location + +Logs are organized by browser and test hierarchy: +``` +test-results/logs/ +├── chromium/ +├── firefox/ +└── mobile-chrome/ + └── test-file/ + └── test-suite/ + └── test-name/ + └── timestamp.log +``` + +### Log Format + +Each log entry includes: +- Timestamp (millisecond precision) +- Log level (INFO, WARN, ERROR) +- Browser name +- Full test path +- Message and metadata + +Example log entry: +``` +[2025-09-04 15:54:14.774] [INFO] [chromium] [dashboard.spec.ts > Dashboard > should display navigation menu] Found menu item: Main +``` + +### Direct Logger Access + +For more control, you can access the logger directly: + +```typescript +test('advanced logging', async ({ logger }) => { + logger.info('Test started'); + logger.debug('Detailed debug info', { userId: 123 }); + logger.warn('Performance issue detected', { loadTime: 5000 }); + logger.error('Critical failure', new Error('Database connection lost')); +}); +``` + +## Debugging + +- Screenshots on failure: `test-results/` +- Videos: `test-results/` (on failure) +- Traces: `test-results/` (on retry) +- **Logs: `test-results/logs/` (organized by browser/test)** +- HTML Report: `pnpm --filter @unraid/e2e test:report` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| UNRAID_URL | Unraid server URL | http://tower.local | +| UNRAID_USERNAME | Username for auth | root | +| UNRAID_PASSWORD | Password for auth | - | +| SLOW_MO | Slow down actions (ms) | 0 | +| DEBUG | Enable debug mode | false | \ No newline at end of file diff --git a/e2e/fixtures/auth.fixture.ts b/e2e/fixtures/auth.fixture.ts new file mode 100644 index 0000000000..7c924de5b0 --- /dev/null +++ b/e2e/fixtures/auth.fixture.ts @@ -0,0 +1,62 @@ +import { test as loggerTest, expect, LoggerFixtures } from './logger.fixture'; +import { LoginPage } from '../utils/pages/login.page.js'; + +type AuthFixtures = { + loginPage: LoginPage; + authenticatedPage: void; +} & LoggerFixtures; + +export const test = loggerTest.extend({ + loginPage: async ({ page }, use) => { + const loginPage = new LoginPage(page); + await use(loginPage); + }, + + authenticatedPage: async ({ page, logger }, use) => { + const username = process.env.UNRAID_USERNAME || 'root'; + const password = process.env.UNRAID_PASSWORD || ''; + + if (password) { + logger.info('Attempting authentication'); + await page.goto('/'); + + // Wait for page to fully load + await page.waitForLoadState('networkidle'); + + // Check if we need to authenticate + const needsAuth = await page.locator('input[name="username"], input[name="password"], input#user, input#pass').count() > 0; + + if (needsAuth) { + logger.debug('Authentication required, filling credentials'); + // Try different selector combinations for username/password fields + const usernameInput = page.locator('input[name="username"], input#user').first(); + const passwordInput = page.locator('input[name="password"], input#pass').first(); + const submitButton = page.locator('button[type="submit"], input[type="submit"], button:has-text("Login")').first(); + + await usernameInput.fill(username); + await passwordInput.fill(password); + await submitButton.click(); + + // Wait for navigation to complete + await page.waitForLoadState('networkidle'); + + // Check if we successfully logged in + const stillOnLogin = page.url().includes('login'); + if (!stillOnLogin) { + logger.info('Authentication successful, saving storage state'); + await page.context().storageState({ path: 'auth.json' }); + } else { + logger.warn('Authentication may have failed - still on login page'); + } + } else { + logger.debug('No authentication needed'); + } + } else { + logger.debug('No password configured, skipping authentication'); + } + + await use(); + }, +}); + +export { expect }; \ No newline at end of file diff --git a/e2e/fixtures/logger.fixture.ts b/e2e/fixtures/logger.fixture.ts new file mode 100644 index 0000000000..3676e8cfc7 --- /dev/null +++ b/e2e/fixtures/logger.fixture.ts @@ -0,0 +1,36 @@ +import { test as base, TestInfo } from '@playwright/test'; +import { TestLogger, interceptConsole } from '../utils/logger'; + +export type LoggerFixtures = { + logger: TestLogger; +}; + +export const test = base.extend({ + logger: [async ({ browserName }, use, testInfo) => { + const logger = new TestLogger(); + logger.attachToTest(testInfo); + + const restoreConsole = interceptConsole(logger); + + logger.info(`Starting test: ${testInfo.title}`); + logger.debug(`Test file: ${testInfo.file}`); + logger.debug(`Browser: ${browserName} (Project: ${testInfo.project.name})`); + logger.debug(`Worker: #${testInfo.workerIndex}`); + logger.debug(`Parallel: ${testInfo.parallelIndex}`); + + await use(logger); + + logger.info(`Test completed with status: ${testInfo.status}`); + if (testInfo.errors.length > 0) { + logger.error('Test errors:', { errors: testInfo.errors }); + } + if (testInfo.duration) { + logger.info(`Test duration: ${testInfo.duration}ms`); + } + + restoreConsole(); + logger.detachFromTest(); + }, { auto: true }] +}); + +export { expect } from '@playwright/test'; \ No newline at end of file diff --git a/e2e/justfile b/e2e/justfile new file mode 100644 index 0000000000..48514ac479 --- /dev/null +++ b/e2e/justfile @@ -0,0 +1,163 @@ +# Justfile for running e2e tests with different environment configurations + +# Default recipe - show available commands +default: + @just --list + +# Run tests against all .env files except .env.example +test-all-envs *args="": + #!/usr/bin/env bash + set -euo pipefail + + # Find all .env.* files except .env.example + env_files=$(find . -maxdepth 1 -name ".env.*" -type f | grep -v ".env.example" | sort) + + if [ -z "$env_files" ]; then + echo "No .env.* files found (excluding .env.example)" + exit 1 + fi + + echo "Found environment files:" + echo "$env_files" + echo "" + + # Run tests for each env file + for env_file in $env_files; do + # Extract environment name from filename + env_name=$(basename "$env_file" | sed 's/^\.env\.//') + + echo "=========================================" + echo "Running tests with: $env_file" + echo "Environment: $env_name" + echo "=========================================" + + # Run tests with dotenvx loading the specific env file + # Use official Playwright environment variables for output paths + PLAYWRIGHT_HTML_OUTPUT_DIR="playwright-report-$env_name" \ + PLAYWRIGHT_HTML_OPEN="never" \ + TEST_RESULTS_DIR="test-results-$env_name" \ + dotenvx run --env-file="$env_file" -- pnpm test {{args}} || { + echo "Tests failed for environment: $env_name" + # Continue with other environments even if one fails + } + + echo "" + done + + echo "=========================================" + echo "All test runs completed" + echo "Reports available in:" + find . -maxdepth 1 -type d -name "playwright-report-*" | sort + echo "" + echo "Artifacts available in:" + find . -maxdepth 1 -type d -name "test-results-*" | sort + +# Run tests for a specific environment file +test-env env_file *args="": + #!/usr/bin/env bash + set -euo pipefail + + if [ ! -f "{{env_file}}" ]; then + echo "Environment file not found: {{env_file}}" + exit 1 + fi + + # Extract environment name from filename + env_name=$(basename "{{env_file}}" | sed 's/^\.env\.//') + + echo "Running tests with: {{env_file}}" + echo "Environment: $env_name" + + # Run tests with official Playwright environment variables + PLAYWRIGHT_HTML_OUTPUT_DIR="playwright-report-$env_name" \ + PLAYWRIGHT_HTML_OPEN="never" \ + TEST_RESULTS_DIR="test-results-$env_name" \ + dotenvx run --env-file="{{env_file}}" -- pnpm test {{args}} + +# Clean all test artifacts and reports +clean: + rm -rf playwright-report-* + rm -rf test-results-* + rm -rf playwright-report + rm -rf test-results + +# List available environment files +list-envs: + @echo "Available environment files:" + @find . -maxdepth 1 -name ".env.*" -type f | grep -v ".env.example" | sort + +# Show report for a specific environment +show-report env_name: + npx playwright show-report "playwright-report-{{env_name}}" + +# Show all reports from all environments +show-all-reports: + #!/usr/bin/env bash + set -euo pipefail + + # Find all playwright report directories + report_dirs=$(find . -maxdepth 1 -type d -name "playwright-report-*" | sort) + + if [ -z "$report_dirs" ]; then + echo "No HTML reports found. Run 'just test-all-envs' first." + exit 1 + fi + + echo "Available reports:" + port=9323 + for report_dir in $report_dirs; do + env_name=$(basename "$report_dir" | sed 's/playwright-report-//') + echo "- $env_name: $report_dir (will serve on port $port)" + ((port++)) + done + echo "" + + # Open each report in browser with different ports + port=9323 + pids=() + for report_dir in $report_dirs; do + env_name=$(basename "$report_dir" | sed 's/playwright-report-//') + echo "Opening report for environment: $env_name on port $port" + npx playwright show-report "$report_dir" --port "$port" & + pids+=($!) + ((port++)) + sleep 1 # Small delay to prevent browser overwhelm + done + + echo "All reports opened in browser" + echo "Press Ctrl+C to stop all report servers" + + # Wait for all background processes and handle cleanup + trap 'echo "Stopping all report servers..."; kill ${pids[@]} 2>/dev/null; exit' INT TERM + wait + +# Run tests in headed mode for a specific environment +test-env-headed env_file *args="": + #!/usr/bin/env bash + set -euo pipefail + + if [ ! -f "{{env_file}}" ]; then + echo "Environment file not found: {{env_file}}" + exit 1 + fi + + # Extract environment name from filename + env_name=$(basename "{{env_file}}" | sed 's/^\.env\.//') + + echo "Running headed tests with: {{env_file}}" + + # Run tests in headed mode with custom output paths + PLAYWRIGHT_HTML_OUTPUT_DIR="playwright-report-$env_name" \ + PLAYWRIGHT_HTML_OPEN="always" \ + TEST_RESULTS_DIR="test-results-$env_name" \ + dotenvx run --env-file="{{env_file}}" -- pnpm test:headed {{args}} + +# Install dependencies including dotenvx +install: + #!/usr/bin/env bash + pnpm install + if ! command -v dotenvx &> /dev/null; then + echo "Installing dotenvx..." + npm install -g @dotenvx/dotenvx + fi + pnpm playwright:install diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000000..2dedfb8752 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,22 @@ +{ + "name": "@unraid/e2e", + "version": "1.0.0", + "private": true, + "description": "End-to-end tests for Unraid WebGUI", + "type": "module", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "test:ui": "playwright test --ui", + "test:report": "playwright show-report", + "playwright:install": "playwright install", + "codegen": "playwright codegen" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@types/node": "^22.10.5", + "dotenv": "^16.4.5", + "winston": "^3.17.0" + } +} \ No newline at end of file diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000000..ab36e55af9 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,76 @@ +import { defineConfig, devices } from '@playwright/test'; +import { config as dotenvConfig } from 'dotenv'; + +dotenvConfig(); + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { + outputFolder: process.env.PLAYWRIGHT_HTML_OUTPUT_DIR || 'playwright-report', + open: process.env.PLAYWRIGHT_HTML_OPEN as 'always' | 'never' | 'on-failure' || 'on-failure' + }], + ['line'], + process.env.CI ? ['github'] : null, + ].filter(Boolean) as any, + outputDir: process.env.TEST_RESULTS_DIR || 'test-results', + use: { + baseURL: process.env.UNRAID_URL || 'http://tower.local', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + + extraHTTPHeaders: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + + ignoreHTTPSErrors: true, + + actionTimeout: 30_000, + navigationTimeout: 30_000, + }, + + timeout: 60000, + + expect: { + timeout: 20_000, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + ], + + webServer: process.env.NO_WEB_SERVER ? undefined : { + command: 'echo "Using external Unraid server"', + url: process.env.UNRAID_URL || 'http://tower.local', + reuseExistingServer: true, + ignoreHTTPSErrors: true, + }, +}); diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 0000000000..cb57b00158 --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from '../fixtures/auth.fixture.js'; +import { LoginPage } from '../utils/pages/login.page.js'; + +test.describe('Authentication', () => { + test('should display login page when not authenticated', async ({ page }) => { + await page.goto('/'); + + const loginPage = new LoginPage(page); + const hasLoginForm = await page.locator('input[name="username"], input[name="password"]').count() > 0; + + if (hasLoginForm) { + await expect(loginPage.usernameInput).toBeVisible(); + await expect(loginPage.passwordInput).toBeVisible(); + await expect(loginPage.submitButton).toBeVisible(); + } else { + console.log('No authentication required or already authenticated'); + } + }); + + test('should login with valid credentials', async ({ page }) => { + const username = process.env.UNRAID_USERNAME || 'root'; + const password = process.env.UNRAID_PASSWORD; + + if (!password) { + test.skip(); + return; + } + + await page.goto('/'); + const loginPage = new LoginPage(page); + + const needsAuth = await page.locator('input[name="username"], input[name="password"]').count() > 0; + + if (needsAuth) { + await loginPage.login(username, password); + + await expect(page).not.toHaveURL(/login/); + + const isLoggedIn = await loginPage.isLoggedIn(); + expect(isLoggedIn).toBeTruthy(); + } + }); + + test('should show error with invalid credentials', async ({ page }) => { + const password = process.env.UNRAID_PASSWORD; + + if (!password) { + test.skip(); + return; + } + + await page.goto('/'); + const loginPage = new LoginPage(page); + + const needsAuth = await page.locator('input[name="username"], input[name="password"]').count() > 0; + + if (needsAuth) { + await loginPage.login('invalid_user', 'invalid_password'); + + await page.waitForTimeout(2000); + + const stillOnLoginPage = await page.locator('input[name="username"], input[name="password"]').count() > 0; + expect(stillOnLoginPage).toBeTruthy(); + } + }); + + test('should preserve session after page reload', async ({ page, authenticatedPage }) => { + await page.goto('/'); + await page.reload(); + + const loginPage = new LoginPage(page); + const isLoggedIn = await loginPage.isLoggedIn(); + + if (process.env.UNRAID_PASSWORD) { + expect(isLoggedIn).toBeTruthy(); + } + }); +}); \ No newline at end of file diff --git a/e2e/tests/dashboard.spec.ts b/e2e/tests/dashboard.spec.ts new file mode 100644 index 0000000000..064d5c313d --- /dev/null +++ b/e2e/tests/dashboard.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '../fixtures/auth.fixture.js'; +import { DashboardPage } from '../utils/pages/dashboard.page.js'; +import { waitForPageLoad } from '../utils/helpers.js'; + +test.describe('Dashboard', () => { + test.beforeEach(async ({ authenticatedPage }) => { + // Authentication handled by fixture + }); + + test('should load dashboard page', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.goto(); + await waitForPageLoad(page); + + await expect(dashboard.header).toBeVisible(); + await expect(dashboard.mainContent).toBeVisible(); + }); + + test('should display navigation menu', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.goto(); + await waitForPageLoad(page); + + await expect(dashboard.navigationMenu).toBeVisible(); + + const menuItems = ['Main', 'Shares', 'Users', 'Settings', 'Plugins', 'Docker', 'VMs']; + for (const item of menuItems) { + const menuItem = dashboard.navigationMenu.locator(`a:has-text("${item}")`); + const exists = await menuItem.count() > 0; + + if (exists) { + console.log(`Found menu item: ${item}`); + } + } + }); + + test('should navigate to different sections', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.goto(); + await waitForPageLoad(page); + + const sectionsToTest = [ + { name: 'Docker', urlPattern: /Docker/i }, + { name: 'Settings', urlPattern: /Settings/i }, + { name: 'Plugins', urlPattern: /Plugins/i } + ]; + + for (const section of sectionsToTest) { + const menuLink = dashboard.navigationMenu.locator(`a:has-text("${section.name}")`); + const sectionExists = await menuLink.count() > 0; + + if (sectionExists) { + await menuLink.first().click(); + await waitForPageLoad(page); + + const url = page.url(); + expect(url).toMatch(section.urlPattern); + + await dashboard.goto(); + await waitForPageLoad(page); + } + } + }); + + test('should display system information', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.goto(); + await waitForPageLoad(page); + + const elementsToCheck = [ + { selector: '.array-status, #array-status, [class*="array"]', name: 'Array Status' }, + { selector: '.system-info, #system-info, [class*="system"]', name: 'System Info' }, + { selector: '.uptime, #uptime, [class*="uptime"]', name: 'Uptime' } + ]; + + for (const element of elementsToCheck) { + const exists = await page.locator(element.selector).count() > 0; + if (exists) { + console.log(`Found ${element.name}`); + await expect(page.locator(element.selector).first()).toBeVisible(); + } + } + }); + + test('should be responsive', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.goto(); + + const viewports = [ + { width: 1920, height: 1080, name: 'Desktop' }, + { width: 768, height: 1024, name: 'Tablet' }, + { width: 375, height: 667, name: 'Mobile' } + ]; + + for (const viewport of viewports) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.waitForTimeout(500); + + await expect(dashboard.mainContent).toBeVisible(); + console.log(`Dashboard visible at ${viewport.name} resolution`); + } + }); +}); \ No newline at end of file diff --git a/e2e/tests/docker.spec.ts b/e2e/tests/docker.spec.ts new file mode 100644 index 0000000000..4e3b3f8433 --- /dev/null +++ b/e2e/tests/docker.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from '../fixtures/auth.fixture.js'; +import { waitForPageLoad, isElementVisible } from '../utils/helpers.js'; + +test.describe('Docker Management', () => { + test.beforeEach(async ({ authenticatedPage, page }) => { + await page.goto('/Docker'); + await waitForPageLoad(page); + }); + + test('should display docker containers list', async ({ page }) => { + const dockerContent = await isElementVisible(page, '#content, .content, main'); + expect(dockerContent).toBeTruthy(); + + const possibleContainerSelectors = [ + '.docker-container', + '.container-item', + 'table tbody tr', + '[data-test*="container"]' + ]; + + let containersFound = false; + for (const selector of possibleContainerSelectors) { + const count = await page.locator(selector).count(); + if (count > 0) { + containersFound = true; + console.log(`Found ${count} containers using selector: ${selector}`); + break; + } + } + + if (!containersFound) { + console.log('No docker containers found or Docker not configured'); + } + }); + + test('should have docker controls', async ({ page }) => { + const controls = [ + { selector: 'button:has-text("Add Container"), a:has-text("Add Container")', name: 'Add Container' }, + { selector: 'button:has-text("Docker Settings"), a:has-text("Docker Settings")', name: 'Docker Settings' }, + { selector: 'input[type="search"], input[placeholder*="Search"]', name: 'Search' } + ]; + + for (const control of controls) { + const exists = await page.locator(control.selector).count() > 0; + if (exists) { + console.log(`Found control: ${control.name}`); + await expect(page.locator(control.selector).first()).toBeVisible(); + } + } + }); + + test('should be able to start/stop containers', async ({ page }) => { + const actionButtons = await page.locator('button[title*="Start"], button[title*="Stop"], a[title*="Start"], a[title*="Stop"]').count(); + + if (actionButtons > 0) { + console.log(`Found ${actionButtons} container action buttons`); + + const firstButton = page.locator('button[title*="Start"], button[title*="Stop"], a[title*="Start"], a[title*="Stop"]').first(); + const isDisabled = await firstButton.isDisabled(); + + if (!isDisabled) { + console.log('Container controls are enabled and functional'); + } + } else { + console.log('No container action buttons found'); + } + }); + + test('should display container details on click', async ({ page }) => { + const containerRows = page.locator('.docker-container, .container-item, table tbody tr'); + const containerCount = await containerRows.count(); + + if (containerCount > 0) { + const firstContainer = containerRows.first(); + const containerName = await firstContainer.textContent(); + console.log(`Clicking on container: ${containerName}`); + + await firstContainer.click(); + await page.waitForTimeout(1000); + + const detailSelectors = [ + '.container-details', + '.docker-details', + '[class*="details"]', + '.modal', + '.popup' + ]; + + for (const selector of detailSelectors) { + const detailsVisible = await isElementVisible(page, selector); + if (detailsVisible) { + console.log('Container details displayed'); + break; + } + } + } + }); + + test('should have docker hub search functionality', async ({ page }) => { + const addButton = page.locator('button:has-text("Add Container"), a:has-text("Add Container")'); + const hasAddButton = await addButton.count() > 0; + + if (hasAddButton) { + await addButton.first().click(); + await waitForPageLoad(page); + + const searchField = page.locator('input[name*="search"], input[placeholder*="Search"], input[type="search"]'); + const hasSearch = await searchField.count() > 0; + + if (hasSearch) { + await searchField.first().fill('nginx'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(2000); + + console.log('Docker Hub search functionality is available'); + } + } + }); +}); \ No newline at end of file diff --git a/e2e/tests/theme-toast.spec.ts b/e2e/tests/theme-toast.spec.ts new file mode 100644 index 0000000000..60b5b0a3c9 --- /dev/null +++ b/e2e/tests/theme-toast.spec.ts @@ -0,0 +1,278 @@ +import { report, reportAndAnnotate } from "@/utils/reporting.js"; +import { test, expect } from "../fixtures/auth.fixture.js"; +import { SettingsPage } from "../utils/pages/settings.page.js"; +import { + triggerToast, + waitForToast, + getToastStyles, + isLightBackground, + isDarkBackground, + hasGoodContrast, + getContrastRatio, + getLuminance, +} from "../utils/toast-helpers.js"; + +test.describe("Theme Toast Styles", () => { + test.beforeEach(async ({ authenticatedPage }) => { + // Authentication handled by fixture + }); + + test("should display appropriate toast styles for light and dark themes", async ({ + page, + }) => { + const settings = new SettingsPage(page); + + // Store original theme to restore later + const originalTheme = await settings.getCurrentTheme(); + + try { + // Test Light Theme (white) + await test.step("Test light theme toasts", async () => { + await settings.setTheme("white"); + + // Test different toast types + const toastTypes: Array<"success" | "info" | "warning" | "error"> = [ + "success", + "info", + "warning", + "error", + ]; + + for (const toastType of toastTypes) { + // Trigger a toast notification + await triggerToast(page, toastType, `Light theme ${toastType} toast`); + + // Get toast styles + const toastElement = await waitForToast(page); + await expect(toastElement).toBeVisible(); + + const styles = await getToastStyles(page); + + // Calculate metrics + const bgLuminance = getLuminance(styles.backgroundRgb); + const textLuminance = getLuminance(styles.textRgb); + const contrastRatio = getContrastRatio( + styles.backgroundRgb, + styles.textRgb + ); + const isLight = isLightBackground(styles.backgroundRgb); + const hasContrast = hasGoodContrast( + styles.backgroundRgb, + styles.textRgb + ); + report(test.info(), { + type: "info", + description: `Light theme ${toastType} toast analysis`, + data: { + background: styles.backgroundColor, + text: styles.color, + bgLuminance: bgLuminance.toFixed(3), + textLuminance: textLuminance.toFixed(3), + contrastRatio: contrastRatio.toFixed(2) + ":1", + isLightBackground: isLight, + meetsWCAG_AA: hasContrast, + status: isLight ? "✅ PASS" : "❌ FAIL", + }, + }); + + // Primary test: Verify light theme has light background + expect + .soft( + isLight, + `Expected light background for ${toastType} toast in light theme. ` + + `Got luminance: ${bgLuminance.toFixed(3)} (threshold: >0.5)` + ) + .toBeTruthy(); + + // Secondary check: Log contrast warning if it doesn't meet WCAG AA + if (!hasContrast) { + reportAndAnnotate(test.info(), { + type: "warning", + description: + `Contrast warning for ${toastType} toast: ${contrastRatio.toFixed( + 2 + )}:1 ` + + `(WCAG AA requires 4.5:1). This may affect readability.`, + }); + } + + // Wait for toast to disappear before next one + await page.waitForTimeout(1000); + } + }); + + // Test Dark Theme (black) + await test.step("Test dark theme toasts", async () => { + await settings.setTheme("black"); + + // Test different toast types + const toastTypes: Array<"success" | "info" | "warning" | "error"> = [ + "success", + "info", + "warning", + "error", + ]; + + for (const toastType of toastTypes) { + // Trigger a toast notification + await triggerToast(page, toastType, `Dark theme ${toastType} toast`); + + // Get toast styles + const toastElement = await waitForToast(page); + await expect(toastElement).toBeVisible(); + + const styles = await getToastStyles(page); + + // Calculate metrics + const bgLuminance = getLuminance(styles.backgroundRgb); + const textLuminance = getLuminance(styles.textRgb); + const contrastRatio = getContrastRatio( + styles.backgroundRgb, + styles.textRgb + ); + const isDark = isDarkBackground(styles.backgroundRgb); + const hasContrast = hasGoodContrast( + styles.backgroundRgb, + styles.textRgb + ); + report(test.info(), { + type: "info", + description: `Dark theme ${toastType} toast analysis`, + data: { + background: styles.backgroundColor, + text: styles.color, + bgLuminance: bgLuminance.toFixed(3), + textLuminance: textLuminance.toFixed(3), + contrastRatio: contrastRatio.toFixed(2) + ":1", + isDarkBackground: isDark, + meetsWCAG_AA: hasContrast, + status: isDark ? "✅ PASS" : "❌ FAIL", + }, + }); + + // Primary test: Verify dark theme has dark background + expect + .soft( + isDark, + `Expected dark background for ${toastType} toast in dark theme. ` + + `Got luminance: ${bgLuminance.toFixed(3)} (threshold: ≤0.5)` + ) + .toBeTruthy(); + + // Secondary check: Log contrast warning if it doesn't meet WCAG AA + if (!hasContrast) { + reportAndAnnotate(test.info(), { + type: "warning", + description: `Contrast warning for ${toastType} toast: ${contrastRatio.toFixed( + 2 + )}:1 (WCAG AA requires 4.5:1). This may affect readability.`, + }); + } + + // Wait for toast to disappear before next one + await page.waitForTimeout(1000); + } + }); + } finally { + // Restore original theme + if (originalTheme && originalTheme !== "black") { + await settings.setTheme(originalTheme as "white" | "black"); + } + } + }); + + test("should maintain toast legibility across theme transitions", async ({ + page, + }) => { + const settings = new SettingsPage(page); + + // Test theme transition + await test.step("Verify contrast remains good during theme switch", async () => { + // Start with light theme + await settings.setTheme("white"); + await triggerToast(page, "success", "Light theme success toast"); + + const lightToast = await waitForToast(page); + await expect(lightToast).toBeVisible(); + const lightStyles = await getToastStyles(page); + + // Wait for toast to clear + await page.waitForTimeout(2000); + + // Switch to dark theme + await settings.setTheme("black"); + await triggerToast(page, "success", "Dark theme success toast"); + + const darkToast = await waitForToast(page); + await expect(darkToast).toBeVisible(); + const darkStyles = await getToastStyles(page); + + // Calculate metrics for both themes + const lightContrast = getContrastRatio( + lightStyles.backgroundRgb, + lightStyles.textRgb + ); + const darkContrast = getContrastRatio( + darkStyles.backgroundRgb, + darkStyles.textRgb + ); + const lightBgLuminance = getLuminance(lightStyles.backgroundRgb); + const darkBgLuminance = getLuminance(darkStyles.backgroundRgb); + + report(test.info(), { + type: "info", + description: "Theme transition analysis", + data: { + light: { + luminance: lightBgLuminance.toFixed(3), + contrast: lightContrast.toFixed(2) + ":1", + meetsWCAG: lightContrast >= 4.5, + }, + dark: { + luminance: darkBgLuminance.toFixed(3), + contrast: darkContrast.toFixed(2) + ":1", + meetsWCAG: darkContrast >= 4.5, + }, + }, + }); + + // Soft expectations with informative messages + expect + .soft( + isLightBackground(lightStyles.backgroundRgb), + `Light theme should have light background. Luminance: ${lightBgLuminance.toFixed( + 3 + )}` + ) + .toBeTruthy(); + + expect + .soft( + isDarkBackground(darkStyles.backgroundRgb), + `Dark theme should have dark background. Luminance: ${darkBgLuminance.toFixed( + 3 + )}` + ) + .toBeTruthy(); + + // Warn about contrast issues but don't fail + if (lightContrast < 4.5) { + reportAndAnnotate(test.info(), { + type: "warning", + description: `Light theme contrast: ${lightContrast.toFixed( + 2 + )}:1 (WCAG AA requires 4.5:1)`, + }); + } + + if (darkContrast < 4.5) { + reportAndAnnotate(test.info(), { + type: "warning", + description: `Dark theme contrast: ${darkContrast.toFixed( + 2 + )}:1 (WCAG AA requires 4.5:1)`, + }); + } + }); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000000..96c7c0c50c --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "lib": ["ESNext", "DOM"], + "types": ["@playwright/test", "node"], + "baseUrl": ".", + "paths": { + "@/*": ["./*"], + "@fixtures/*": ["./fixtures/*"], + "@utils/*": ["./utils/*"], + "@tests/*": ["./tests/*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "test-results", "playwright-report"] +} \ No newline at end of file diff --git a/e2e/utils/helpers.ts b/e2e/utils/helpers.ts new file mode 100644 index 0000000000..93cc634f25 --- /dev/null +++ b/e2e/utils/helpers.ts @@ -0,0 +1,53 @@ +import { Page } from '@playwright/test'; + +export async function waitForPageLoad(page: Page) { + await page.waitForLoadState('networkidle'); +} + +export async function takeScreenshot(page: Page, name: string) { + await page.screenshot({ + path: `screenshots/${name}-${Date.now()}.png`, + fullPage: true + }); +} + +export async function clearCookies(page: Page) { + await page.context().clearCookies(); +} + +export async function setLocalStorage(page: Page, key: string, value: string) { + await page.evaluate(([k, v]) => { + localStorage.setItem(k, v); + }, [key, value]); +} + +export async function getLocalStorage(page: Page, key: string) { + return await page.evaluate((k) => { + return localStorage.getItem(k); + }, key); +} + +export async function waitForElement(page: Page, selector: string, timeout = 30000) { + await page.locator(selector).waitFor({ state: 'visible', timeout }); +} + +export async function isElementVisible(page: Page, selector: string) { + return await page.locator(selector).isVisible(); +} + +export async function clickAndWaitForNavigation(page: Page, selector: string) { + await Promise.all([ + page.waitForNavigation(), + page.click(selector) + ]); +} + +export async function fillForm(page: Page, formData: Record) { + for (const [selector, value] of Object.entries(formData)) { + await page.fill(selector, value); + } +} + +export function generateTestId(): string { + return `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} \ No newline at end of file diff --git a/e2e/utils/logger.ts b/e2e/utils/logger.ts new file mode 100644 index 0000000000..d73ecfe94b --- /dev/null +++ b/e2e/utils/logger.ts @@ -0,0 +1,205 @@ +import winston from 'winston'; +import * as path from 'path'; +import * as fs from 'fs'; +import { TestInfo } from '@playwright/test'; + +const { combine, timestamp, printf, colorize } = winston.format; + +export class TestLogger { + private logger: winston.Logger; + private testInfo: TestInfo | null = null; + private logFilePath: string | null = null; + private browserName: string = 'unknown'; + + constructor() { + this.logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: combine( + timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), + printf(({ level, message, timestamp, testPath, browser, ...meta }) => { + const testContext = testPath ? `[${testPath}]` : ''; + const browserContext = browser ? `[${browser}]` : ''; + const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ''; + return `[${timestamp}] [${level.toUpperCase()}] ${browserContext} ${testContext} ${message}${metaStr}`; + }) + ), + transports: [ + new winston.transports.Console({ + level: 'error', + format: combine( + colorize(), + printf(({ message, browser }) => { + const browserPrefix = browser ? `[${browser}]` : ''; + return `[TEST] ${browserPrefix} ${message}`; + }) + ) + }) + ] + }); + } + + private ensureLogDirectory(testInfo: TestInfo): string { + const outputDir = process.env.TEST_RESULTS_DIR || 'test-results'; + const browserName = testInfo.project.name.replace(/[^a-z0-9]/gi, '-').toLowerCase(); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + + // Build simple hierarchy: logs/browser/test-file/suite/test-name/ + const pathParts = [outputDir, 'logs', browserName]; + + // Add test file name + const testFileName = path.basename(testInfo.file, '.spec.ts') + .replace(/[^a-z0-9]/gi, '-').toLowerCase(); + pathParts.push(testFileName); + + // Add test suite hierarchy and test name + const titleParts = testInfo.titlePath.map(part => + part.replace(/[^a-z0-9]/gi, '-').toLowerCase() + ); + pathParts.push(...titleParts); + + const logDir = path.join(...pathParts); + + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + + this.browserName = testInfo.project.name; + + return path.join(logDir, `${timestamp}.log`); + } + + attachToTest(testInfo: TestInfo): void { + this.testInfo = testInfo; + this.logFilePath = this.ensureLogDirectory(testInfo); + + const existingFileTransport = this.logger.transports.find( + t => t instanceof winston.transports.File + ); + if (existingFileTransport) { + this.logger.remove(existingFileTransport); + } + + this.logger.add( + new winston.transports.File({ + filename: this.logFilePath, + format: this.logger.format + }) + ); + + testInfo.attachments.push({ + name: 'test-logs', + path: this.logFilePath, + contentType: 'text/plain' + }); + } + + detachFromTest(): void { + const fileTransport = this.logger.transports.find( + t => t instanceof winston.transports.File + ); + if (fileTransport) { + this.logger.remove(fileTransport); + } + this.testInfo = null; + this.logFilePath = null; + } + + private getTestPath(): string { + if (this.testInfo) { + return this.testInfo.titlePath.join(' > '); + } + return ''; + } + + log(message: string, meta?: any): void { + this.logger.info(message, { testPath: this.getTestPath(), browser: this.browserName, ...meta }); + } + + info(message: string, meta?: any): void { + this.logger.info(message, { testPath: this.getTestPath(), browser: this.browserName, ...meta }); + } + + warn(message: string, meta?: any): void { + this.logger.warn(message, { testPath: this.getTestPath(), browser: this.browserName, ...meta }); + } + + error(message: string | Error, meta?: any): void { + const msg = message instanceof Error ? message.message : message; + const errorMeta = message instanceof Error ? { stack: message.stack, ...meta } : meta; + this.logger.error(msg, { testPath: this.getTestPath(), browser: this.browserName, ...errorMeta }); + } + + debug(message: string, meta?: any): void { + this.logger.debug(message, { testPath: this.getTestPath(), browser: this.browserName, ...meta }); + } + + verbose(message: string, meta?: any): void { + this.logger.verbose(message, { testPath: this.getTestPath(), browser: this.browserName, ...meta }); + } + + http(message: string, meta?: any): void { + this.logger.http(message, { testPath: this.getTestPath(), browser: this.browserName, ...meta }); + } +} + +let globalLogger: TestLogger | null = null; + +export function getLogger(): TestLogger { + if (!globalLogger) { + globalLogger = new TestLogger(); + } + return globalLogger; +} + +export function interceptConsole(logger: TestLogger): () => void { + const originalConsole = { + log: console.log, + info: console.info, + warn: console.warn, + error: console.error, + debug: console.debug, + }; + + console.log = (...args: any[]) => { + const message = args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg) + ).join(' '); + logger.log(message); + }; + + console.info = (...args: any[]) => { + const message = args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg) + ).join(' '); + logger.info(message); + }; + + console.warn = (...args: any[]) => { + const message = args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg) + ).join(' '); + logger.warn(message); + }; + + console.error = (...args: any[]) => { + const message = args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg) + ).join(' '); + logger.error(message); + }; + + console.debug = (...args: any[]) => { + const message = args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg) + ).join(' '); + logger.debug(message); + }; + + return () => { + console.log = originalConsole.log; + console.info = originalConsole.info; + console.warn = originalConsole.warn; + console.error = originalConsole.error; + console.debug = originalConsole.debug; + }; +} \ No newline at end of file diff --git a/e2e/utils/pages/dashboard.page.ts b/e2e/utils/pages/dashboard.page.ts new file mode 100644 index 0000000000..b1b568038a --- /dev/null +++ b/e2e/utils/pages/dashboard.page.ts @@ -0,0 +1,47 @@ +import { Page, Locator } from '@playwright/test'; + +export class DashboardPage { + readonly page: Page; + readonly header: Locator; + readonly navigationMenu: Locator; + readonly mainContent: Locator; + readonly arrayStatus: Locator; + readonly dockerContainers: Locator; + readonly vmList: Locator; + + constructor(page: Page) { + this.page = page; + this.header = page.locator('#header').first(); + this.navigationMenu = page.locator('#menu'); + this.mainContent = page.locator('#content, .content, main'); + this.arrayStatus = page.locator('[data-test="array-status"], .array-status, #array-status'); + this.dockerContainers = page.locator('[data-test="docker-containers"], .docker-containers, #docker-containers'); + this.vmList = page.locator('[data-test="vm-list"], .vm-list, #vm-list'); + } + + async goto() { + await this.page.goto('/Dashboard'); + } + + async isLoaded() { + await this.mainContent.waitFor({ state: 'visible' }); + } + + async getArrayStatus() { + return await this.arrayStatus.textContent(); + } + + async getDockerContainerCount() { + const containers = await this.dockerContainers.locator('.container-item, tr').count(); + return containers; + } + + async getVMCount() { + const vms = await this.vmList.locator('.vm-item, tr').count(); + return vms; + } + + async navigateTo(menuItem: string) { + await this.navigationMenu.locator(`a:has-text("${menuItem}")`).first().click(); + } +} \ No newline at end of file diff --git a/e2e/utils/pages/login.page.ts b/e2e/utils/pages/login.page.ts new file mode 100644 index 0000000000..ff3449267b --- /dev/null +++ b/e2e/utils/pages/login.page.ts @@ -0,0 +1,36 @@ +import { Page, Locator } from '@playwright/test'; + +export class LoginPage { + readonly page: Page; + readonly usernameInput: Locator; + readonly passwordInput: Locator; + readonly submitButton: Locator; + readonly errorMessage: Locator; + + constructor(page: Page) { + this.page = page; + this.usernameInput = page.locator('input[name="username"]'); + this.passwordInput = page.locator('input[name="password"]'); + this.submitButton = page.locator('button[type="submit"], input[type="submit"]'); + this.errorMessage = page.locator('.error, .alert-error, [role="alert"]'); + } + + async goto() { + await this.page.goto('/'); + } + + async login(username: string, password: string) { + await this.usernameInput.fill(username); + await this.passwordInput.fill(password); + await this.submitButton.click(); + } + + async isLoggedIn() { + const loginElements = await this.page.locator('input[name="username"], input[name="password"]').count(); + return loginElements === 0; + } + + async getErrorMessage() { + return await this.errorMessage.textContent(); + } +} \ No newline at end of file diff --git a/e2e/utils/pages/settings.page.ts b/e2e/utils/pages/settings.page.ts new file mode 100644 index 0000000000..8f38635b97 --- /dev/null +++ b/e2e/utils/pages/settings.page.ts @@ -0,0 +1,44 @@ +import { Page, Locator } from '@playwright/test'; + +export class SettingsPage { + readonly page: Page; + readonly themeSelector: Locator; + readonly applyButton: Locator; + readonly displaySettingsLink: Locator; + + constructor(page: Page) { + this.page = page; + this.themeSelector = page.locator('select[name="theme"]'); + this.applyButton = page.locator('input[type="submit"][value="Apply"], button:has-text("Apply")'); + this.displaySettingsLink = page.locator('a:has-text("Display Settings")'); + } + + async goto() { + await this.page.goto('/Settings'); + } + + async goToDisplaySettings() { + await this.page.goto('/Settings/DisplaySettings'); + // Fallback: click on Display Settings if direct URL doesn't work + const currentUrl = this.page.url(); + if (!currentUrl.includes('DisplaySettings')) { + await this.goto(); + await this.displaySettingsLink.click(); + await this.page.waitForLoadState('networkidle'); + } + } + + async setTheme(theme: 'white' | 'black') { + await this.goToDisplaySettings(); + await this.themeSelector.selectOption(theme); + await this.applyButton.click(); + await this.page.waitForLoadState('networkidle'); + // Wait a bit for theme to apply + await this.page.waitForTimeout(1_000); + } + + async getCurrentTheme(): Promise { + await this.goToDisplaySettings(); + return await this.themeSelector.inputValue(); + } +} \ No newline at end of file diff --git a/e2e/utils/reporting.ts b/e2e/utils/reporting.ts new file mode 100644 index 0000000000..33c82814f2 --- /dev/null +++ b/e2e/utils/reporting.ts @@ -0,0 +1,34 @@ +import { TestInfo } from "@playwright/test"; +import { TestLogger } from "./logger"; + +type Report = { + type: "info" | "warning" | "error"; + description: string; + data?: Record; +}; + +export function report(testInfo: TestInfo, message: Report, logger?: TestLogger) { + const logMessage = `Report: ${message.description}`; + + if (logger) { + switch (message.type) { + case "error": + logger.error(logMessage, message.data); + break; + case "warning": + logger.warn(logMessage, message.data); + break; + case "info": + default: + logger.info(logMessage, message.data); + break; + } + } else { + console.log(`[${message.type.toUpperCase()}]`, logMessage, message.data || ''); + } +} + +export function reportAndAnnotate(testInfo: TestInfo, message: Report, logger?: TestLogger) { + report(testInfo, message, logger); + testInfo.annotations.push(message); +} diff --git a/e2e/utils/toast-helpers.ts b/e2e/utils/toast-helpers.ts new file mode 100644 index 0000000000..00dc92e1b8 --- /dev/null +++ b/e2e/utils/toast-helpers.ts @@ -0,0 +1,98 @@ +import { Page } from '@playwright/test'; + +export async function triggerToast(page: Page, type: 'success' | 'info' | 'warning' | 'error' | 'default' = 'default', message?: string) { + const toastMessage = message || `Test ${type} notification`; + + // Execute JavaScript to trigger a toast using the Unraid toast system + await page.evaluate(([msg, toastType]) => { + if ((window as any).toast) { + if (toastType === 'default') { + // Simple toast call + (window as any).toast(msg, { duration: 5000 }); + } else { + // Typed toast call (success, info, warning, error) + (window as any).toast[toastType](msg, { duration: 5000 }); + } + } else { + console.error('Toast function not available'); + } + }, [toastMessage, type]); + + // Give the toast a moment to appear + await page.waitForTimeout(500); +} + +export async function waitForToast(page: Page) { + // Look for toast elements in the Unraid toaster structure + const toastLocator = page.locator('ol.toaster li').first(); + + // Wait for the toast to be visible + await toastLocator.waitFor({ state: 'visible', timeout: 5000 }); + + return toastLocator; +} + +export async function getToastStyles(page: Page) { + const toast = await waitForToast(page); + + // Get computed styles of the toast + const styles = await toast.evaluate((el) => { + const computed = window.getComputedStyle(el); + return { + backgroundColor: computed.backgroundColor, + color: computed.color, + // Convert rgb to a more readable format + backgroundRgb: computed.backgroundColor.match(/\d+/g)?.map(Number) || [], + textRgb: computed.color.match(/\d+/g)?.map(Number) || [] + }; + }); + + return styles; +} + +export function isLightBackground(rgb: number[]): boolean { + if (rgb.length < 3) return true; + // Calculate luminance + const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255; + return luminance > 0.5; +} + +export function isDarkBackground(rgb: number[]): boolean { + return !isLightBackground(rgb); +} + +export function getContrastRatio(bgRgb: number[], textRgb: number[]): number { + if (bgRgb.length < 3 || textRgb.length < 3) return 21; // Max contrast + + // Calculate relative luminance + const getLuminance = (rgb: number[]) => { + const [r, g, b] = rgb.map(val => { + const sRGB = val / 255; + return sRGB <= 0.03928 + ? sRGB / 12.92 + : Math.pow((sRGB + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + + const bgLuminance = getLuminance(bgRgb); + const textLuminance = getLuminance(textRgb); + + // Calculate contrast ratio + const lighter = Math.max(bgLuminance, textLuminance); + const darker = Math.min(bgLuminance, textLuminance); + const contrast = (lighter + 0.05) / (darker + 0.05); + + return contrast; +} + +export function hasGoodContrast(bgRgb: number[], textRgb: number[]): boolean { + // WCAG AA requires at least 4.5:1 for normal text + return getContrastRatio(bgRgb, textRgb) >= 4.5; +} + +export function getLuminance(rgb: number[]): number { + if (rgb.length < 3) return 0.5; + // Calculate luminance (0 = dark, 1 = light) + return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255; +} \ No newline at end of file diff --git a/package.json b/package.json index 51d937fcf8..28e5727fc4 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,11 @@ "dev": "pnpm -r dev", "unraid:deploy": "pnpm -r unraid:deploy", "test": "pnpm -r test", + "test:e2e": "pnpm --filter @unraid/e2e test", + "test:e2e:headed": "pnpm --filter @unraid/e2e test:headed", + "test:e2e:debug": "pnpm --filter @unraid/e2e test:debug", + "test:e2e:ui": "pnpm --filter @unraid/e2e test:ui", + "playwright:install": "pnpm --filter @unraid/e2e playwright:install", "lint": "pnpm -r lint", "lint:fix": "pnpm -r lint:fix", "type-check": "pnpm -r type-check", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2d69d4dcd..5c362c8485 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -498,6 +498,21 @@ importers: specifier: 8.8.1 version: 8.8.1 + e2e: + devDependencies: + '@playwright/test': + specifier: ^1.49.0 + version: 1.55.0 + '@types/node': + specifier: ^22.10.5 + version: 22.18.0 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + winston: + specifier: ^3.17.0 + version: 3.17.0 + packages/unraid-api-plugin-connect: dependencies: '@unraid/shared': @@ -1190,7 +1205,7 @@ importers: version: 1.9.0(@typescript-eslint/utils@8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(@vue/compiler-sfc@3.5.20)(eslint-import-resolver-node@0.3.9)(eslint@9.34.0(jiti@2.5.1))(magicast@0.3.5)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) '@nuxt/test-utils': specifier: 3.19.2 - version: 3.19.2(@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.20)(vue@3.5.20(typescript@5.9.2)))(@vitest/ui@3.2.4)(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(jsdom@26.1.0)(magicast@0.3.5)(typescript@5.9.2)(vitest@3.2.4) + version: 3.19.2(@playwright/test@1.55.0)(@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.20)(vue@3.5.20(typescript@5.9.2)))(@vitest/ui@3.2.4)(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(jsdom@26.1.0)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4) '@pinia/testing': specifier: 1.0.2 version: 1.0.2(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))) @@ -4333,6 +4348,11 @@ packages: resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.55.0': + resolution: {integrity: sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==} + engines: {node: '>=18'} + hasBin: true + '@pm2/agent@2.1.1': resolution: {integrity: sha512-0V9ckHWd/HSC8BgAbZSoq8KXUG81X97nSkAxmhKDhmF8vanyaoc1YXwc2KVkbWz82Rg4gjd2n9qiT3i7bdvGrQ==} @@ -7548,10 +7568,6 @@ packages: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} - dotenv@16.5.0: - resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} - engines: {node: '>=12'} - dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -8536,6 +8552,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -10920,6 +10941,16 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + playwright-core@1.55.0: + resolution: {integrity: sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.55.0: + resolution: {integrity: sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -15427,7 +15458,7 @@ snapshots: '@whatwg-node/fetch': 0.10.8 chalk: 4.1.2 debug: 4.4.1(supports-color@5.5.0) - dotenv: 16.5.0 + dotenv: 16.6.1 graphql: 16.11.0 graphql-request: 6.1.0(graphql@16.11.0) http-proxy-agent: 7.0.2 @@ -16668,7 +16699,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/test-utils@3.19.2(@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.20)(vue@3.5.20(typescript@5.9.2)))(@vitest/ui@3.2.4)(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(jsdom@26.1.0)(magicast@0.3.5)(typescript@5.9.2)(vitest@3.2.4)': + '@nuxt/test-utils@3.19.2(@playwright/test@1.55.0)(@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.20)(vue@3.5.20(typescript@5.9.2)))(@vitest/ui@3.2.4)(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(jsdom@26.1.0)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4)': dependencies: '@nuxt/kit': 3.17.5(magicast@0.3.5) c12: 3.0.4(magicast@0.3.5) @@ -16692,14 +16723,16 @@ snapshots: tinyexec: 1.0.1 ufo: 1.6.1 unplugin: 2.3.5 - vitest-environment-nuxt: 1.0.1(@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.20)(vue@3.5.20(typescript@5.9.2)))(@vitest/ui@3.2.4)(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(jsdom@26.1.0)(magicast@0.3.5)(typescript@5.9.2)(vitest@3.2.4) + vitest-environment-nuxt: 1.0.1(@playwright/test@1.55.0)(@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.20)(vue@3.5.20(typescript@5.9.2)))(@vitest/ui@3.2.4)(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(jsdom@26.1.0)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4) vue: 3.5.20(typescript@5.9.2) optionalDependencies: + '@playwright/test': 1.55.0 '@testing-library/vue': 8.1.0(@vue/compiler-sfc@3.5.20)(vue@3.5.20(typescript@5.9.2)) '@vitest/ui': 3.2.4(vitest@3.2.4) '@vue/test-utils': 2.4.6 happy-dom: 18.0.1 jsdom: 26.1.0 + playwright-core: 1.55.0 vitest: 3.2.4(@types/node@22.18.0)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1) transitivePeerDependencies: - magicast @@ -17141,6 +17174,10 @@ snapshots: '@pkgr/core@0.2.7': {} + '@playwright/test@1.55.0': + dependencies: + playwright: 1.55.0 + '@pm2/agent@2.1.1': dependencies: async: 3.2.6 @@ -19600,7 +19637,7 @@ snapshots: chokidar: 4.0.3 confbox: 0.2.2 defu: 6.1.4 - dotenv: 16.5.0 + dotenv: 16.6.1 exsolve: 1.0.7 giget: 2.0.0 jiti: 2.5.1 @@ -20699,12 +20736,10 @@ snapshots: dotenv-expand@12.0.1: dependencies: - dotenv: 16.5.0 + dotenv: 16.6.1 dotenv@16.4.7: {} - dotenv@16.5.0: {} - dotenv@16.6.1: {} dotenv@17.2.1: {} @@ -21978,6 +22013,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -24675,6 +24713,14 @@ snapshots: dependencies: find-up: 3.0.0 + playwright-core@1.55.0: {} + + playwright@1.55.0: + dependencies: + playwright-core: 1.55.0 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} pm2-axon-rpc@0.7.1: @@ -27170,9 +27216,9 @@ snapshots: tsx: 4.20.5 yaml: 2.8.1 - vitest-environment-nuxt@1.0.1(@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.20)(vue@3.5.20(typescript@5.9.2)))(@vitest/ui@3.2.4)(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(jsdom@26.1.0)(magicast@0.3.5)(typescript@5.9.2)(vitest@3.2.4): + vitest-environment-nuxt@1.0.1(@playwright/test@1.55.0)(@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.20)(vue@3.5.20(typescript@5.9.2)))(@vitest/ui@3.2.4)(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(jsdom@26.1.0)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4): dependencies: - '@nuxt/test-utils': 3.19.2(@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.20)(vue@3.5.20(typescript@5.9.2)))(@vitest/ui@3.2.4)(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(jsdom@26.1.0)(magicast@0.3.5)(typescript@5.9.2)(vitest@3.2.4) + '@nuxt/test-utils': 3.19.2(@playwright/test@1.55.0)(@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.20)(vue@3.5.20(typescript@5.9.2)))(@vitest/ui@3.2.4)(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(jsdom@26.1.0)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4) transitivePeerDependencies: - '@cucumber/cucumber' - '@jest/globals' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c032cc1e14..c86cefef93 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,4 @@ packages: - "./unraid-ui" - "./web" - "./packages/*" + - "./e2e"