End-to-End Testing Document
Parent Document: TEST_PLAN.mdVersion: 1.2 | Date: January 5, 2026
1. Overview
1.1 Purpose
This document defines the end-to-end (E2E) testing strategy for the Dustac Environmental Monitoring Dashboard. E2E tests validate complete user workflows through the browser, simulating real user interactions.
1.2 Scope
- Critical user journeys
- Cross-feature workflows
- Browser-based automation
- Visual regression testing
1.3 Tools & Framework
| Tool | Version | Purpose |
|---|---|---|
| Playwright | ^1.40+ | Browser automation |
| @playwright/test | ^1.40+ | Test runner |
| Chromium/Firefox/Webkit | Latest | Browser engines |
2. Test Configuration
2.1 Playwright Config (playwright.config.ts)
typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['junit', { outputFile: 'test-results/junit.xml' }]
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
}
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
});2.2 Test Commands
bash
# Run all E2E tests
pnpm test:e2e
# Run in headed mode (visible browser)
pnpm test:e2e -- --headed
# Run specific test file
pnpm test:e2e -- e2e/upload.spec.ts
# Run tests matching pattern
pnpm test:e2e -- -g "upload CSV"
# Generate HTML report
pnpm test:e2e:report
# Debug mode
pnpm test:e2e -- --debug
# Update snapshots
pnpm test:e2e -- --update-snapshots3. Page Object Model
3.1 Base Page
typescript
// e2e/pages/BasePage.ts
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
readonly page: Page;
readonly header: Locator;
readonly sidebar: Locator;
readonly loadingSpinner: Locator;
constructor(page: Page) {
this.page = page;
this.header = page.locator('header');
this.sidebar = page.locator('[data-testid="sidebar"]');
this.loadingSpinner = page.locator('[data-testid="loading"]');
}
async waitForLoad() {
await this.loadingSpinner.waitFor({ state: 'hidden', timeout: 10000 });
}
async navigateTo(path: string) {
await this.page.goto(path);
await this.waitForLoad();
}
}3.2 Login Page
typescript
// e2e/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.locator('input[name="email"]');
this.passwordInput = page.locator('input[name="password"]');
this.loginButton = page.locator('button[type="submit"]');
this.errorMessage = page.locator('[data-testid="error-message"]');
}
async goto() {
await this.navigateTo('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
await this.page.waitForURL('**/dashboard');
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}3.3 Upload Page
typescript
// e2e/pages/UploadPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class UploadPage extends BasePage {
readonly fileInput: Locator;
readonly uploadButton: Locator;
readonly progressBar: Locator;
readonly successMessage: Locator;
readonly errorMessage: Locator;
readonly fileList: Locator;
constructor(page: Page) {
super(page);
this.fileInput = page.locator('input[type="file"]');
this.uploadButton = page.locator('[data-testid="upload-button"]');
this.progressBar = page.locator('[data-testid="progress-bar"]');
this.successMessage = page.locator('[data-testid="success-message"]');
this.errorMessage = page.locator('[data-testid="error-message"]');
this.fileList = page.locator('[data-testid="file-list"]');
}
async goto() {
await this.navigateTo('/upload');
}
async uploadFile(filePath: string) {
await this.fileInput.setInputFiles(filePath);
await expect(this.fileList).toContainText(filePath.split('/').pop()!);
await this.uploadButton.click();
}
async waitForUploadComplete() {
await this.progressBar.waitFor({ state: 'visible' });
await this.successMessage.waitFor({ state: 'visible', timeout: 60000 });
}
async expectUploadSuccess(recordCount: number) {
await expect(this.successMessage).toContainText(`${recordCount} records imported`);
}
}3.4 Report Page
typescript
// e2e/pages/ReportPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class ReportPage extends BasePage {
readonly uploadSelector: Locator;
readonly dateRangeStart: Locator;
readonly dateRangeEnd: Locator;
readonly generateButton: Locator;
readonly downloadButton: Locator;
readonly timelineChart: Locator;
readonly chartPreview: Locator;
constructor(page: Page) {
super(page);
this.uploadSelector = page.locator('[data-testid="upload-selector"]');
this.dateRangeStart = page.locator('[data-testid="date-start"]');
this.dateRangeEnd = page.locator('[data-testid="date-end"]');
this.generateButton = page.locator('[data-testid="generate-button"]');
this.downloadButton = page.locator('[data-testid="download-button"]');
this.timelineChart = page.locator('[data-testid="device-timeline-chart"]');
this.chartPreview = page.locator('[data-testid="chart-preview"]');
}
async goto() {
await this.navigateTo('/reports/generate');
}
async selectUpload(uploadName: string) {
await this.uploadSelector.click();
await this.page.locator(`text=${uploadName}`).click();
}
async generateReport() {
await this.generateButton.click();
await this.downloadButton.waitFor({ state: 'visible', timeout: 120000 });
}
async downloadPDF(): Promise<string> {
const [download] = await Promise.all([
this.page.waitForEvent('download'),
this.downloadButton.click()
]);
const path = await download.path();
return path!;
}
async verifyTimelineChartVisible() {
await expect(this.timelineChart).toBeVisible();
}
}4. E2E Test Cases
4.1 Authentication Flow
TC-E2E-AUTH-001: Complete Login Flow
typescript
// e2e/tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test.describe('Authentication', () => {
test('TC-E2E-AUTH-001: should login successfully with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test.user@dustac.com.au', 'testpassword123');
// Verify redirect to dashboard
await expect(page).toHaveURL(/.*dashboard/);
// Verify user info displayed
await expect(page.locator('[data-testid="user-menu"]')).toContainText('test.user');
});
test('TC-E2E-AUTH-002: should show error for invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.emailInput.fill('test@dustac.com.au');
await loginPage.passwordInput.fill('wrongpassword');
await loginPage.loginButton.click();
await loginPage.expectError('Invalid login credentials');
await expect(page).toHaveURL(/.*login/);
});
test('TC-E2E-AUTH-003: should redirect unauthenticated user to login', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/.*login/);
});
test('TC-E2E-AUTH-004: should logout successfully', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test.user@dustac.com.au', 'testpassword123');
// Click logout
await page.locator('[data-testid="user-menu"]').click();
await page.locator('text=Logout').click();
await expect(page).toHaveURL(/.*login/);
});
});4.2 CSV Upload Flow
TC-E2E-UPLOAD-001: Upload Valid CSV
typescript
// e2e/tests/upload.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { UploadPage } from '../pages/UploadPage';
import path from 'path';
test.describe('CSV Upload', () => {
test.beforeEach(async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test.user@dustac.com.au', 'testpassword123');
});
test('TC-E2E-UPLOAD-001: should upload valid CSV successfully', async ({ page }) => {
const uploadPage = new UploadPage(page);
const testFile = path.join(__dirname, '../fixtures/valid-single-device.csv');
await uploadPage.goto();
await uploadPage.uploadFile(testFile);
await uploadPage.waitForUploadComplete();
await uploadPage.expectUploadSuccess(288);
});
test('TC-E2E-UPLOAD-002: should show preview before upload', async ({ page }) => {
const uploadPage = new UploadPage(page);
const testFile = path.join(__dirname, '../fixtures/valid-single-device.csv');
await uploadPage.goto();
await uploadPage.fileInput.setInputFiles(testFile);
// Verify preview
await expect(uploadPage.fileList).toContainText('valid-single-device.csv');
await expect(page.locator('[data-testid="row-count"]')).toContainText('288');
await expect(page.locator('[data-testid="column-count"]')).toBeVisible();
});
test('TC-E2E-UPLOAD-003: should reject invalid CSV with error', async ({ page }) => {
const uploadPage = new UploadPage(page);
const testFile = path.join(__dirname, '../fixtures/missing-columns.csv');
await uploadPage.goto();
await uploadPage.uploadFile(testFile);
await expect(uploadPage.errorMessage).toContainText('Missing required column');
});
test('TC-E2E-UPLOAD-004: should handle UTF-8 BOM encoding', async ({ page }) => {
const uploadPage = new UploadPage(page);
const testFile = path.join(__dirname, '../fixtures/utf8-bom.csv');
await uploadPage.goto();
await uploadPage.uploadFile(testFile);
await uploadPage.waitForUploadComplete();
await uploadPage.expectUploadSuccess(100);
});
});4.3 Report Generation Flow
TC-E2E-REPORT-001: Generate PDF Report
typescript
// e2e/tests/report.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { UploadPage } from '../pages/UploadPage';
import { ReportPage } from '../pages/ReportPage';
import fs from 'fs';
import path from 'path';
test.describe('Report Generation', () => {
test.beforeEach(async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test.user@dustac.com.au', 'testpassword123');
// Upload test data
const uploadPage = new UploadPage(page);
await uploadPage.goto();
await uploadPage.uploadFile(path.join(__dirname, '../fixtures/valid-multi-device.csv'));
await uploadPage.waitForUploadComplete();
});
test('TC-E2E-REPORT-001: should generate PDF report', async ({ page }) => {
const reportPage = new ReportPage(page);
await reportPage.goto();
await reportPage.selectUpload('valid-multi-device.csv');
await reportPage.generateReport();
const pdfPath = await reportPage.downloadPDF();
// Verify PDF file exists and has content
expect(fs.existsSync(pdfPath)).toBe(true);
const stats = fs.statSync(pdfPath);
expect(stats.size).toBeGreaterThan(10000); // At least 10KB
});
test('TC-E2E-REPORT-002: should display device timeline chart with gaps', async ({ page }) => {
const reportPage = new ReportPage(page);
await reportPage.goto();
await reportPage.selectUpload('valid-multi-device.csv');
// Verify timeline chart is visible
await reportPage.verifyTimelineChartVisible();
// Verify chart shows multiple devices
const deviceLabels = page.locator('[data-testid="device-timeline-chart"] .device-label');
await expect(deviceLabels).toHaveCount(5); // 5 devices in test file
});
test('TC-E2E-REPORT-003: should show data gap indicators in timeline', async ({ page }) => {
const reportPage = new ReportPage(page);
// Use file with known gaps
await page.goto('/upload');
const uploadPage = new UploadPage(page);
await uploadPage.uploadFile(path.join(__dirname, '../fixtures/data-with-gaps.csv'));
await uploadPage.waitForUploadComplete();
await reportPage.goto();
await reportPage.selectUpload('data-with-gaps.csv');
// Verify gap indicators
const gapIndicators = page.locator('[data-testid="gap-indicator"]');
await expect(gapIndicators.first()).toBeVisible();
});
test('TC-E2E-REPORT-004: should generate report within 60 seconds', async ({ page }) => {
const reportPage = new ReportPage(page);
const startTime = Date.now();
await reportPage.goto();
await reportPage.selectUpload('valid-multi-device.csv');
await reportPage.generateReport();
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(60000);
});
});4.4 Dashboard Flow
TC-E2E-DASH-001: Dashboard Data Display
typescript
// e2e/tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test.describe('Dashboard', () => {
test.beforeEach(async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test.user@dustac.com.au', 'testpassword123');
});
test('TC-E2E-DASH-001: should display KPI cards', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('[data-testid="kpi-total-measurements"]')).toBeVisible();
await expect(page.locator('[data-testid="kpi-active-devices"]')).toBeVisible();
await expect(page.locator('[data-testid="kpi-avg-pm25"]')).toBeVisible();
});
test('TC-E2E-DASH-002: should filter by date range', async ({ page }) => {
await page.goto('/dashboard');
// Select date range
await page.locator('[data-testid="date-range-picker"]').click();
await page.locator('text=Last 7 Days').click();
// Verify chart updates
await expect(page.locator('[data-testid="chart-container"]')).toBeVisible();
});
test('TC-E2E-DASH-003: should filter by device', async ({ page }) => {
await page.goto('/dashboard');
// Select specific device
await page.locator('[data-testid="device-filter"]').click();
await page.locator('text=DEVICE-001').click();
// Verify chart shows only selected device data
await expect(page.locator('[data-testid="chart-legend"]')).toContainText('DEVICE-001');
});
});4.5 Complete User Journey
TC-E2E-JOURNEY-001: New User Complete Workflow
typescript
// e2e/tests/user-journey.spec.ts
import { test, expect } from '@playwright/test';
import path from 'path';
test.describe('Complete User Journey', () => {
test('TC-E2E-JOURNEY-001: new user uploads data and generates report', async ({ page }) => {
// Step 1: Login
await page.goto('/login');
await page.fill('input[name="email"]', 'test.user@dustac.com.au');
await page.fill('input[name="password"]', 'testpassword123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/.*dashboard/);
// Step 2: Navigate to upload
await page.click('[data-testid="nav-upload"]');
await expect(page).toHaveURL(/.*upload/);
// Step 3: Upload CSV
const testFile = path.join(__dirname, '../fixtures/valid-multi-device.csv');
await page.setInputFiles('input[type="file"]', testFile);
await page.click('[data-testid="upload-button"]');
await expect(page.locator('[data-testid="success-message"]')).toBeVisible({ timeout: 60000 });
// Step 4: Navigate to dashboard
await page.click('[data-testid="nav-dashboard"]');
await expect(page.locator('[data-testid="kpi-total-measurements"]')).toBeVisible();
// Step 5: Generate report
await page.click('[data-testid="nav-reports"]');
await page.click('[data-testid="generate-report"]');
await page.click('[data-testid="upload-selector"]');
await page.click('text=valid-multi-device.csv');
await page.click('[data-testid="generate-button"]');
// Step 6: Download PDF
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('[data-testid="download-button"]', { timeout: 120000 })
]);
expect(download.suggestedFilename()).toContain('.pdf');
});
});5. Test Fixtures
5.1 Test Data Files
e2e/
├── fixtures/
│ ├── valid-single-device.csv # 1 device, 288 records
│ ├── valid-multi-device.csv # 5 devices, 1440 records
│ ├── data-with-gaps.csv # Contains 2-hour gaps
│ ├── missing-columns.csv # Invalid: missing PM2.5
│ ├── utf8-bom.csv # UTF-8 with BOM
│ └── large-dataset.csv # 50,000 records5.2 Auth State
typescript
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'e2e/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'test.user@dustac.com.au');
await page.fill('input[name="password"]', 'testpassword123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/.*dashboard/);
await page.context().storageState({ path: authFile });
});6. CI/CD Integration
6.1 GitHub Actions Workflow
yaml
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install
- run: pnpm exec playwright install --with-deps
- name: Start Supabase
run: pnpm supabase:start
- name: Run E2E tests
run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 307. Test Summary
7.1 Test Case Count
| Category | Total | P0 | P1 |
|---|---|---|---|
| Authentication | 4 | 2 | 2 |
| Upload | 4 | 2 | 2 |
| Report Generation | 4 | 2 | 2 |
| Dashboard | 3 | 1 | 2 |
| User Journey | 1 | 1 | 0 |
| Total | 16 | 8 | 8 |
7.2 Coverage
- Critical user journeys: 100%
- Core features: 95%
- Edge cases: 70%
Related Documents:
- TEST_PLAN.md - Main test plan
- TEST_UNIT.md - Unit tests
- TEST_INTEGRATION.md - Integration tests
- TEST_SYSTEM.md - System tests