diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 00000000..3e1b84c6 --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,12 @@ +# Test user credentials +# Create a test user in your development database with these credentials +TEST_USER_EMAIL=test@example.com +TEST_USER_PASSWORD=TestPassword123! + +# TOTP secret for the test user +# Get this from the user_credentials table after setting up TOTP for the test user +# You can get it with: SELECT totp_secret FROM user_credentials WHERE user_id = '...'; +TEST_USER_TOTP_SECRET=JBSWY3DPEHPK3PXP + +# Base URL (override in npm scripts) +BASE_URL=http://localhost:4000 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..2e2dd085 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Playwright +test-results/ +playwright-report/ +playwright/.cache/ +tests/.auth/ + +# Environment +.env +.env.local diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..a852b9b6 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,311 @@ +# Towerops E2E Tests + +End-to-end tests using Playwright for testing the Towerops application against local development or staging environments. + +## Setup + +### 1. Install Dependencies + +```bash +cd e2e +npm install +npx playwright install +``` + +### 2. Create Test User + +You need a test user with TOTP enabled: + +**Option A: Using Phoenix Console (Recommended)** + +```elixir +# Start console +cd .. +iex -S mix + +# Create test user +alias Towerops.{Accounts, Organizations, Repo} + +{:ok, user} = Accounts.register_user(%{ + email: "test@example.com", + password: "TestPassword123!", + password_confirmation: "TestPassword123!" +}) + +# Enable TOTP (this will print the secret) +{:ok, credential} = Accounts.create_user_credential(user, %{ + type: :totp, + totp_secret: "JBSWY3DPEHPK3PXP" # Use this secret in .env +}) + +# Create an organization for the test user +{:ok, org} = Organizations.create_organization(%{name: "Test Org"}, user.id) +``` + +**Option B: Using the Web UI** + +1. Register a new user at http://localhost:4000/users/register +2. Log in and enable TOTP in settings +3. Get the TOTP secret from the database: + ```sql + SELECT totp_secret FROM user_credentials WHERE user_id = ''; + ``` + +### 3. Configure Environment + +```bash +cp .env.example .env +``` + +Edit `.env` and set: +- `TEST_USER_EMAIL` - Your test user email +- `TEST_USER_PASSWORD` - Your test user password +- `TEST_USER_TOTP_SECRET` - TOTP secret from step 2 + +## Running Tests + +### Local Development + +Make sure Phoenix is running first: +```bash +cd .. +mix phx.server +``` + +Then run tests: +```bash +cd e2e +npm test +``` + +### Against Staging + +```bash +npm run test:staging +``` + +### Other Commands + +```bash +# Run tests with UI (interactive mode) +npm run test:ui + +# Run tests with browser visible +npm run test:headed + +# Debug a specific test +npm run test:debug -- tests/devices.spec.ts + +# Generate tests by recording actions +npm run codegen + +# View last test report +npm run report +``` + +## Test Organization + +### `tests/auth.setup.ts` + +Authentication setup that runs before all other tests. Logs in once and saves the session state so other tests don't need to authenticate. + +### Test Files + +- `organizations.spec.ts` - Organization listing and switching +- `devices.spec.ts` - Device listing, details, search/filter +- `alerts.spec.ts` - Alert viewing, filtering, resolution + +### Adding New Tests + +1. Create a new `.spec.ts` file in `tests/` +2. Import test utilities: + ```typescript + import { test, expect } from '@playwright/test'; + ``` +3. Write tests using Playwright's API + +Example: +```typescript +import { test, expect } from '@playwright/test'; + +test.describe('My Feature', () => { + test('can do something', async ({ page }) => { + await page.goto('/my-page'); + await expect(page.getByRole('heading')).toBeVisible(); + }); +}); +``` + +## Configuration + +### Environment Variables + +- `BASE_URL` - Base URL of the application (default: http://localhost:4000) +- `TEST_USER_EMAIL` - Test user email +- `TEST_USER_PASSWORD` - Test user password +- `TEST_USER_TOTP_SECRET` - TOTP secret for test user + +### Playwright Config + +Edit `playwright.config.ts` to: +- Add/remove browsers +- Change timeout settings +- Configure reporters +- Add mobile viewports +- Enable video recording + +## Best Practices + +### 1. Use Data Test Attributes + +Add `data-test` attributes to important elements in your templates: + +```heex +
+

<%= @device.name %>

+
+``` + +Then in tests: +```typescript +await page.locator('[data-test="device-card"]').first().click(); +``` + +### 2. Wait for Network Idle + +For LiveView pages with dynamic content: +```typescript +await page.waitForLoadState('networkidle'); +``` + +### 3. Use Semantic Selectors + +Prefer role-based selectors when possible: +```typescript +await page.getByRole('button', { name: 'Submit' }).click(); +await page.getByRole('heading', { name: 'Devices' }).toBeVisible(); +``` + +### 4. Handle Optional Elements + +Check if elements exist before interacting: +```typescript +if (await page.getByText('No results').count() > 0) { + // Handle empty state +} +``` + +### 5. Avoid Hard Timeouts + +Use `waitFor` methods instead of `waitForTimeout`: +```typescript +// Good +await page.getByText('Success').waitFor(); + +// Bad +await page.waitForTimeout(3000); +``` + +## CI/CD Integration + +### GitHub Actions / GitLab CI + +```yaml +e2e-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Install dependencies + run: | + cd e2e + npm ci + npx playwright install --with-deps + - name: Run tests + run: | + cd e2e + BASE_URL=${{ secrets.STAGING_URL }} npm test + env: + TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }} + TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }} + TEST_USER_TOTP_SECRET: ${{ secrets.TEST_USER_TOTP_SECRET }} + - name: Upload test results + if: always() + uses: actions/upload-artifact@v3 + with: + name: playwright-report + path: e2e/playwright-report/ +``` + +## Debugging + +### Visual Debugging + +```bash +# Open test in UI mode +npm run test:ui + +# Run specific test with browser visible +npm run test:headed -- tests/devices.spec.ts +``` + +### Debug Mode + +```bash +# Opens Playwright Inspector +npm run test:debug -- tests/devices.spec.ts +``` + +### Screenshots and Videos + +Failed tests automatically capture: +- Screenshots (in `test-results/`) +- Videos (in `test-results/`) +- Traces (view with `npx playwright show-trace `) + +### Console Logs + +Add console logging in tests: +```typescript +test('debug test', async ({ page }) => { + page.on('console', msg => console.log('Browser:', msg.text())); + await page.goto('/devices'); +}); +``` + +## Troubleshooting + +### "Authentication failed" or TOTP errors + +- Verify TOTP secret is correct in `.env` +- Check test user exists and has TOTP enabled +- Ensure test user has access to at least one organization + +### Tests timing out + +- Increase timeout in `playwright.config.ts` +- Check Phoenix server is running +- Verify BASE_URL is correct +- Check network connectivity to staging + +### "Element not found" errors + +- Add `data-test` attributes to elements +- Use `page.waitForLoadState('networkidle')` before assertions +- Check if LiveView is fully mounted before interacting + +### Browser not launching + +```bash +# Reinstall browsers +npx playwright install --with-deps +``` + +## Resources + +- [Playwright Documentation](https://playwright.dev/) +- [Playwright Test API](https://playwright.dev/docs/api/class-test) +- [Best Practices](https://playwright.dev/docs/best-practices) +- [Debugging Guide](https://playwright.dev/docs/debug) diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..4d288057 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,20 @@ +{ + "name": "towerops-e2e", + "version": "1.0.0", + "description": "End-to-end tests for Towerops", + "scripts": { + "test": "playwright test", + "test:local": "BASE_URL=http://localhost:4000 playwright test", + "test:staging": "BASE_URL=https://staging.towerops.net playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "codegen": "playwright codegen http://localhost:4000", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "^22.0.0", + "otplib": "^13.0.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..319eabbe --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,96 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['html'], + ['list'], + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.BASE_URL || 'http://localhost:4000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + /* Screenshot on failure */ + screenshot: 'only-on-failure', + + /* Video on failure */ + video: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + // Setup project - runs first to authenticate + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + }, + + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + // Use prepared auth state + storageState: 'tests/.auth/user.json', + }, + dependencies: ['setup'], + }, + + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + storageState: 'tests/.auth/user.json', + }, + dependencies: ['setup'], + }, + + { + name: 'webkit', + use: { + ...devices['Desktop Safari'], + storageState: 'tests/.auth/user.json', + }, + dependencies: ['setup'], + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { + // ...devices['Pixel 5'], + // storageState: 'tests/.auth/user.json', + // }, + // dependencies: ['setup'], + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'mix phx.server', + // url: 'http://localhost:4000', + // reuseExistingServer: !process.env.CI, + // cwd: '..', + // }, +}); diff --git a/e2e/setup.sh b/e2e/setup.sh new file mode 100755 index 00000000..81e16b74 --- /dev/null +++ b/e2e/setup.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +set -e + +echo "🎭 Setting up Playwright E2E tests..." + +# Install dependencies +echo "📦 Installing npm dependencies..." +npm install + +# Install Playwright browsers +echo "🌐 Installing Playwright browsers..." +npx playwright install + +# Create .env if it doesn't exist +if [ ! -f .env ]; then + echo "📝 Creating .env file from template..." + cp .env.example .env + echo "" + echo "⚠️ IMPORTANT: Edit .env and set your test user credentials!" + echo " TEST_USER_EMAIL, TEST_USER_PASSWORD, and TEST_USER_TOTP_SECRET" + echo "" +fi + +# Create .auth directory +mkdir -p tests/.auth + +echo "✅ Setup complete!" +echo "" +echo "Next steps:" +echo " 1. Edit .env with your test user credentials" +echo " 2. Create a test user (see README.md)" +echo " 3. Run: npm test" diff --git a/e2e/tests/alerts.spec.ts b/e2e/tests/alerts.spec.ts new file mode 100644 index 00000000..1ed0d600 --- /dev/null +++ b/e2e/tests/alerts.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Alerts', () => { + test.beforeEach(async ({ page }) => { + // Navigate to organizations and select the first one + await page.goto('/orgs'); + await page.locator('[data-test="organization-card"]').first().click(); + await expect(page).toHaveURL(/\/orgs\/[^\/]+/); + }); + + test('can view alerts page', async ({ page }) => { + // Navigate to alerts + await page.getByRole('link', { name: /alerts/i }).click(); + + // Check URL + await expect(page).toHaveURL(/\/alerts/); + + // Check for page heading + await expect(page.getByRole('heading', { name: /alerts/i })).toBeVisible(); + }); + + test('alerts page shows alert list', async ({ page }) => { + await page.goto('/alerts'); + + await page.waitForLoadState('networkidle'); + + // Check for alerts list or empty state + const hasAlerts = await page.locator('[data-test="alert-item"], table tbody tr').count() > 0; + const hasEmptyState = await page.locator('text=/no alerts|no active alerts/i').count() > 0; + + // Should show either alerts or empty state + expect(hasAlerts || hasEmptyState).toBeTruthy(); + }); + + test('can filter alerts by status', async ({ page }) => { + await page.goto('/alerts'); + + // Look for status filter buttons/tabs + const activeFilter = page.getByRole('button', { name: /active|open/i }); + const resolvedFilter = page.getByRole('button', { name: /resolved|closed/i }); + + if (await activeFilter.count() > 0) { + // Click active filter + await activeFilter.click(); + await page.waitForLoadState('networkidle'); + + // URL or UI should reflect active filter + // Adjust based on your implementation + } + + if (await resolvedFilter.count() > 0) { + // Click resolved filter + await resolvedFilter.click(); + await page.waitForLoadState('networkidle'); + + // URL or UI should reflect resolved filter + } + }); + + test('alert items show severity indicators', async ({ page }) => { + await page.goto('/alerts'); + + await page.waitForLoadState('networkidle'); + + // Check for severity indicators (critical/warning) + const alertItems = page.locator('[data-test="alert-item"], table tbody tr'); + + if (await alertItems.count() > 0) { + const firstAlert = alertItems.first(); + await expect(firstAlert).toBeVisible(); + + // Should have severity indicator (color, icon, badge) + // Adjust based on your alert item markup + } + }); + + test('can view alert details', async ({ page }) => { + await page.goto('/alerts'); + + await page.waitForLoadState('networkidle'); + + // Click first alert if any exist + const firstAlert = page.locator('[data-test="alert-item"], table tbody tr a').first(); + + if (await firstAlert.count() > 0) { + await firstAlert.click(); + + // Should show alert details (either modal or detail page) + await page.waitForTimeout(500); + + // Check for alert detail content + // Adjust based on your implementation (modal vs page) + const hasModal = await page.locator('[role="dialog"], .modal').count() > 0; + const hasDetailPage = page.url().includes('/alerts/'); + + expect(hasModal || hasDetailPage).toBeTruthy(); + } + }); + + test('can acknowledge/resolve an alert', async ({ page }) => { + await page.goto('/alerts'); + + await page.waitForLoadState('networkidle'); + + // Look for resolve/acknowledge button + const resolveButton = page.getByRole('button', { name: /resolve|acknowledge/i }).first(); + + if (await resolveButton.count() > 0) { + await resolveButton.click(); + + // Should show confirmation or success message + await expect( + page.locator('text=/resolved|acknowledged/i') + ).toBeVisible({ timeout: 3000 }); + } + }); +}); diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts new file mode 100644 index 00000000..4f38bb23 --- /dev/null +++ b/e2e/tests/auth.setup.ts @@ -0,0 +1,64 @@ +import { test as setup, expect } from '@playwright/test'; +import { authenticator } from 'otplib'; + +const authFile = 'tests/.auth/user.json'; + +/** + * Authentication setup + * + * This runs before all other tests to authenticate once and save the session state. + * Other tests will reuse this authentication state instead of logging in each time. + * + * Setup: + * 1. Create a test user in your database + * 2. Enable TOTP for that user + * 3. Set TEST_USER_EMAIL, TEST_USER_PASSWORD, and TEST_USER_TOTP_SECRET in .env + */ +setup('authenticate', async ({ page }) => { + const email = process.env.TEST_USER_EMAIL || 'test@example.com'; + const password = process.env.TEST_USER_PASSWORD || 'password'; + const totpSecret = process.env.TEST_USER_TOTP_SECRET; + + if (!totpSecret) { + throw new Error( + 'TEST_USER_TOTP_SECRET is not set. ' + + 'Create a test user with TOTP enabled and set the secret in .env' + ); + } + + console.log(`Authenticating as ${email}...`); + + // Navigate to login page + await page.goto('/users/log_in'); + + // Fill in email and password + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(password); + + // Submit login form + await page.getByRole('button', { name: 'Sign in' }).click(); + + // Wait for TOTP page + await page.waitForURL('**/users/log_in/totp'); + + // Generate TOTP code + const token = authenticator.generate(totpSecret); + console.log(`Generated TOTP token: ${token}`); + + // Fill in TOTP code + await page.getByLabel(/authentication code|totp|code/i).fill(token); + + // Submit TOTP form + await page.getByRole('button', { name: /verify|submit|continue/i }).click(); + + // Wait for redirect to dashboard/home + await page.waitForURL('**/orgs'); + + // Verify we're logged in by checking for user menu or organization elements + await expect(page.getByRole('link', { name: /organizations|dashboard/i })).toBeVisible(); + + console.log('Authentication successful!'); + + // Save authenticated state + await page.context().storageState({ path: authFile }); +}); diff --git a/e2e/tests/devices.spec.ts b/e2e/tests/devices.spec.ts new file mode 100644 index 00000000..d64230a8 --- /dev/null +++ b/e2e/tests/devices.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Devices', () => { + test.beforeEach(async ({ page }) => { + // Navigate to organizations and select the first one + await page.goto('/orgs'); + await page.locator('[data-test="organization-card"]').first().click(); + await expect(page).toHaveURL(/\/orgs\/[^\/]+/); + }); + + test('can view devices list', async ({ page }) => { + // Navigate to devices + await page.getByRole('link', { name: /devices/i }).click(); + + // Check URL + await expect(page).toHaveURL(/\/devices/); + + // Check for page heading + await expect(page.getByRole('heading', { name: /devices/i })).toBeVisible(); + }); + + test('devices list shows device information', async ({ page }) => { + await page.goto('/devices'); + + // Wait for devices to load + await page.waitForSelector('[data-test="device-row"], [data-role="device-item"]', { + timeout: 5000, + }).catch(() => { + // If no test attributes, just check for any table rows or list items + return page.waitForSelector('table tbody tr, [role="list"] > div'); + }); + + // Check that device rows contain expected information + // Adjust selectors based on your actual markup + const firstDevice = page.locator('table tbody tr, [role="list"] > div').first(); + await expect(firstDevice).toBeVisible(); + + // Device should have a name and status indicator + // These will need to be adjusted based on your actual DOM structure + }); + + test('can view device details', async ({ page }) => { + await page.goto('/devices'); + + // Wait for devices to load + await page.waitForLoadState('networkidle'); + + // Click first device + const firstDeviceLink = page.locator('table tbody tr a, [data-test="device-link"]').first(); + await firstDeviceLink.click(); + + // Should navigate to device detail page + await expect(page).toHaveURL(/\/devices\/[^\/]+/); + + // Check for device detail sections + // Adjust these based on your actual device detail page + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + }); + + test('can search/filter devices', async ({ page }) => { + await page.goto('/devices'); + + // Look for search input + const searchInput = page.getByPlaceholder(/search|filter/i); + + // If search exists, test it + if (await searchInput.count() > 0) { + await searchInput.fill('test'); + await page.waitForTimeout(500); // Wait for debounce/filtering + + // Results should update + // This is a basic check - adjust based on your implementation + } + }); + + test('device status indicators work', async ({ page }) => { + await page.goto('/devices'); + + await page.waitForLoadState('networkidle'); + + // Check for status indicators (up/down/warning) + // Adjust based on your status indicator implementation + const statusIndicators = page.locator('[data-status], .status-indicator, [class*="status-"]'); + + if (await statusIndicators.count() > 0) { + const firstStatus = statusIndicators.first(); + await expect(firstStatus).toBeVisible(); + + // Status should have appropriate styling/color + // You might check for specific classes or aria attributes + } + }); +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 00000000..bc7e57e6 --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,74 @@ +import { Page, expect } from '@playwright/test'; + +/** + * Navigate to organizations and select the first one + */ +export async function selectFirstOrganization(page: Page) { + await page.goto('/orgs'); + const firstOrg = page.locator('[data-test="organization-card"]').first(); + await firstOrg.waitFor({ timeout: 10000 }); + await firstOrg.click(); + await expect(page).toHaveURL(/\/orgs\/[^\/]+/); +} + +/** + * Wait for LiveView to be fully connected + */ +export async function waitForLiveView(page: Page) { + // Wait for Phoenix LiveView to connect + await page.waitForFunction(() => { + // @ts-ignore + return window.liveSocket && window.liveSocket.isConnected(); + }, { timeout: 10000 }).catch(() => { + // Fallback to network idle if LiveView check doesn't work + return page.waitForLoadState('networkidle'); + }); +} + +/** + * Check if an element exists on the page + */ +export async function elementExists(page: Page, selector: string): Promise { + return (await page.locator(selector).count()) > 0; +} + +/** + * Get status emoji from page title + */ +export async function getStatusEmoji(page: Page): Promise { + const title = await page.title(); + const match = title.match(/^([🔴🟡🟢])/); + return match ? match[1] : null; +} + +/** + * Wait for page title to update with specific emoji + */ +export async function waitForStatusEmoji(page: Page, expectedEmoji: string, timeout = 5000) { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const emoji = await getStatusEmoji(page); + if (emoji === expectedEmoji) { + return true; + } + await page.waitForTimeout(100); + } + + throw new Error(`Status emoji did not update to ${expectedEmoji} within ${timeout}ms`); +} + +/** + * Fill a form and submit it + */ +export async function fillAndSubmitForm( + page: Page, + formSelector: string, + fields: Record +) { + for (const [label, value] of Object.entries(fields)) { + await page.getByLabel(new RegExp(label, 'i')).fill(value); + } + + await page.locator(formSelector).getByRole('button', { name: /submit|save|create/i }).click(); +} diff --git a/e2e/tests/organizations.spec.ts b/e2e/tests/organizations.spec.ts new file mode 100644 index 00000000..ba6e7069 --- /dev/null +++ b/e2e/tests/organizations.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Organizations', () => { + test('can view organizations list', async ({ page }) => { + await page.goto('/orgs'); + + // Check for page heading + await expect(page.getByRole('heading', { name: 'Organizations' })).toBeVisible(); + + // Should show at least one organization + await expect(page.locator('[data-test="organization-card"]').first()).toBeVisible(); + }); + + test('can switch between organizations', async ({ page }) => { + await page.goto('/orgs'); + + // Get first organization name + const firstOrg = page.locator('[data-test="organization-card"]').first(); + await firstOrg.waitFor(); + const orgName = await firstOrg.textContent(); + + // Click to view organization + await firstOrg.click(); + + // Should navigate to organization dashboard + await expect(page).toHaveURL(/\/orgs\/[^\/]+/); + + // Organization name should appear in navigation or header + if (orgName) { + await expect(page.locator('text=' + orgName.trim())).toBeVisible(); + } + }); + + test('organization dashboard shows key sections', async ({ page }) => { + // Navigate to organizations and select first one + await page.goto('/orgs'); + await page.locator('[data-test="organization-card"]').first().click(); + + // Wait for dashboard to load + await expect(page).toHaveURL(/\/orgs\/[^\/]+/); + + // Check for key navigation items or sections + // Adjust these based on your actual dashboard layout + const navigationItems = [ + 'Devices', + 'Sites', + 'Alerts', + ]; + + for (const item of navigationItems) { + await expect( + page.getByRole('link', { name: new RegExp(item, 'i') }) + ).toBeVisible(); + } + }); +}); diff --git a/e2e/tests/status-indicator.spec.ts b/e2e/tests/status-indicator.spec.ts new file mode 100644 index 00000000..fc127e49 --- /dev/null +++ b/e2e/tests/status-indicator.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from '@playwright/test'; +import { selectFirstOrganization, getStatusEmoji } from './helpers'; + +test.describe('Status Indicator', () => { + test.beforeEach(async ({ page }) => { + await selectFirstOrganization(page); + }); + + test('page title shows status emoji', async ({ page }) => { + // Navigate to devices page + await page.goto('/devices'); + + // Get current status emoji from title + const emoji = await getStatusEmoji(page); + + // Should have a status emoji (🔴, 🟡, or 🟢) + expect(['🔴', '🟡', '🟢']).toContain(emoji); + }); + + test('status emoji appears on all pages', async ({ page }) => { + const pages = ['/devices', '/alerts', '/sites']; + + for (const path of pages) { + await page.goto(path); + await page.waitForLoadState('networkidle'); + + const emoji = await getStatusEmoji(page); + expect(['🔴', '🟡', '🟢', null]).toContain(emoji); + + // If there's an organization context, should have emoji + const hasOrgContext = await page.locator('[data-test="organization-selector"], .organization-name').count() > 0; + if (hasOrgContext) { + expect(emoji).not.toBeNull(); + } + } + }); + + test('status emoji matches organization health', async ({ page }) => { + await page.goto('/devices'); + + const emoji = await getStatusEmoji(page); + + // Navigate to alerts to check if status makes sense + await page.goto('/alerts'); + + // If there are critical alerts, emoji should be red + const hasCriticalAlerts = await page.locator('[data-severity="critical"], [data-alert-type="device_down"]').count() > 0; + const hasWarningAlerts = await page.locator('[data-severity="warning"]').count() > 0; + + if (hasCriticalAlerts) { + expect(emoji).toBe('🔴'); + } else if (hasWarningAlerts) { + expect(emoji).toBe('🟡'); + } else { + // If no alerts, should be green (or might still be yellow/red if we just resolved them) + expect(['🟢', '🟡', '🔴']).toContain(emoji); + } + }); + + test('favicon is static stoplight', async ({ page }) => { + await page.goto('/devices'); + + // Get favicon link element + const faviconHref = await page.locator('link[rel="icon"]').first().getAttribute('href'); + + // Should point to our stoplight favicon + expect(faviconHref).toContain('favicon'); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..35ca0bd7 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "types": ["node", "@playwright/test"] + }, + "include": ["tests/**/*.ts"], + "exclude": ["node_modules"] +}