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:
Graham McIntire 2026-03-06 15:02:56 -06:00
parent c86ca57864
commit 17bf1f83e8
No known key found for this signature in database
13 changed files with 975 additions and 0 deletions

12
e2e/.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
}

View 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();
}
});
});

View 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
View 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"]
}