feat: add Playwright e2e test suite
- Set up Playwright in dedicated e2e directory - Multi-environment support (local, staging) - TOTP authentication handling with otplib - Test coverage for organizations, devices, alerts, status indicators - Helper utilities for common test operations - Comprehensive README with setup and usage instructions - Setup script for quick initialization
This commit is contained in:
parent
c86ca57864
commit
17bf1f83e8
13 changed files with 975 additions and 0 deletions
12
e2e/.env.example
Normal file
12
e2e/.env.example
Normal file
|
|
@ -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
|
||||
14
e2e/.gitignore
vendored
Normal file
14
e2e/.gitignore
vendored
Normal file
|
|
@ -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
|
||||
311
e2e/README.md
Normal file
311
e2e/README.md
Normal file
|
|
@ -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 = '<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
|
||||
<div data-test="device-card">
|
||||
<h3 data-test="device-name"><%= @device.name %></h3>
|
||||
</div>
|
||||
```
|
||||
|
||||
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 <trace-file>`)
|
||||
|
||||
### 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)
|
||||
20
e2e/package.json
Normal file
20
e2e/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
96
e2e/playwright.config.ts
Normal file
96
e2e/playwright.config.ts
Normal file
|
|
@ -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: '..',
|
||||
// },
|
||||
});
|
||||
33
e2e/setup.sh
Executable file
33
e2e/setup.sh
Executable file
|
|
@ -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"
|
||||
117
e2e/tests/alerts.spec.ts
Normal file
117
e2e/tests/alerts.spec.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
64
e2e/tests/auth.setup.ts
Normal file
64
e2e/tests/auth.setup.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
93
e2e/tests/devices.spec.ts
Normal file
93
e2e/tests/devices.spec.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
74
e2e/tests/helpers.ts
Normal file
74
e2e/tests/helpers.ts
Normal file
|
|
@ -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<boolean> {
|
||||
return (await page.locator(selector).count()) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status emoji from page title
|
||||
*/
|
||||
export async function getStatusEmoji(page: Page): Promise<string | null> {
|
||||
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<string, string>
|
||||
) {
|
||||
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();
|
||||
}
|
||||
56
e2e/tests/organizations.spec.ts
Normal file
56
e2e/tests/organizations.spec.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
69
e2e/tests/status-indicator.spec.ts
Normal file
69
e2e/tests/status-indicator.spec.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
16
e2e/tsconfig.json
Normal file
16
e2e/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue