{{ theme.skipToContentLabel || 'Skip to content' }}

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

ToolVersionPurpose
Playwright^1.40+Browser automation
@playwright/test^1.40+Test runner
Chromium/Firefox/WebkitLatestBrowser 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-snapshots

3. 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 records

5.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: 30

7. Test Summary

7.1 Test Case Count

CategoryTotalP0P1
Authentication422
Upload422
Report Generation422
Dashboard312
User Journey110
Total1688

7.2 Coverage

  • Critical user journeys: 100%
  • Core features: 95%
  • Edge cases: 70%

Related Documents: