Backend (@backend-dev): - Add ReportService with PDF/CSV generation (reportlab, pandas) - Implement Report API endpoints (POST, GET, DELETE, download) - Add ReportRepository and schemas - Configure storage with auto-cleanup (30 days) - Rate limiting: 10 downloads/minute - Professional PDF templates with charts support Frontend (@frontend-dev): - Integrate Recharts for data visualization - Add CostBreakdown, TimeSeries, ComparisonBar charts - Implement scenario comparison page with multi-select - Add dark/light mode toggle with ThemeProvider - Create Reports page with generation form and list - Add new UI components: checkbox, dialog, tabs, label, skeleton - Implement useComparison and useReports hooks QA (@qa-engineer): - Setup Playwright E2E testing framework - Create 7 test spec files with 94 test cases - Add visual regression testing with baselines - Configure multi-browser testing (Chrome, Firefox, WebKit) - Add mobile responsive tests - Create test fixtures and helpers - Setup GitHub Actions CI workflow Documentation (@spec-architect): - Create detailed kanban-v0.4.0.md with 27 tasks - Update progress.md with v0.4.0 tracking - Create v0.4.0 planning prompt Features: ✅ PDF/CSV Report Generation ✅ Interactive Charts (Pie, Area, Bar) ✅ Scenario Comparison (2-4 scenarios) ✅ Dark/Light Mode Toggle ✅ E2E Test Suite (94 tests) Dependencies added: - Backend: reportlab, pandas, slowapi - Frontend: recharts, date-fns, @radix-ui/react-checkbox/dialog/tabs - Testing: @playwright/test 27 tasks completed, 100% v0.4.0 implementation
206 lines
4.9 KiB
TypeScript
206 lines
4.9 KiB
TypeScript
/**
|
|
* E2E Test Utilities
|
|
*
|
|
* Shared utilities and helpers for E2E tests
|
|
*/
|
|
|
|
import { Page, expect, APIRequestContext } from '@playwright/test';
|
|
|
|
// Base URL for API calls
|
|
const API_BASE_URL = process.env.VITE_API_URL || 'http://localhost:8000/api/v1';
|
|
|
|
/**
|
|
* Navigate to a page and wait for it to be ready
|
|
*/
|
|
export async function navigateTo(page: Page, path: string) {
|
|
await page.goto(path);
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
|
|
/**
|
|
* Wait for loading states to complete
|
|
*/
|
|
export async function waitForLoading(page: Page) {
|
|
// Wait for any loading text to disappear
|
|
const loadingElement = page.getByText('Loading...');
|
|
await expect(loadingElement).toHaveCount(0, { timeout: 30000 });
|
|
}
|
|
|
|
/**
|
|
* Wait for table to be populated
|
|
*/
|
|
export async function waitForTableData(page: Page, tableTestId?: string) {
|
|
const tableSelector = tableTestId
|
|
? `[data-testid="${tableTestId}"] tbody tr`
|
|
: 'table tbody tr';
|
|
|
|
// Wait for at least one row to be present
|
|
await page.waitForSelector(tableSelector, { timeout: 10000 });
|
|
}
|
|
|
|
/**
|
|
* Create a scenario via API
|
|
*/
|
|
export async function createScenarioViaAPI(
|
|
request: APIRequestContext,
|
|
scenario: {
|
|
name: string;
|
|
description?: string;
|
|
tags?: string[];
|
|
region: string;
|
|
}
|
|
) {
|
|
const response = await request.post(`${API_BASE_URL}/scenarios`, {
|
|
data: scenario,
|
|
});
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Delete a scenario via API
|
|
*/
|
|
export async function deleteScenarioViaAPI(
|
|
request: APIRequestContext,
|
|
scenarioId: string
|
|
) {
|
|
const response = await request.delete(`${API_BASE_URL}/scenarios/${scenarioId}`);
|
|
|
|
// Accept 204 (No Content) or 200 (OK) or 404 (already deleted)
|
|
expect([200, 204, 404]).toContain(response.status());
|
|
}
|
|
|
|
/**
|
|
* Start a scenario via API
|
|
*/
|
|
export async function startScenarioViaAPI(
|
|
request: APIRequestContext,
|
|
scenarioId: string
|
|
) {
|
|
const response = await request.post(`${API_BASE_URL}/scenarios/${scenarioId}/start`);
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Stop a scenario via API
|
|
*/
|
|
export async function stopScenarioViaAPI(
|
|
request: APIRequestContext,
|
|
scenarioId: string
|
|
) {
|
|
const response = await request.post(`${API_BASE_URL}/scenarios/${scenarioId}/stop`);
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Send test logs to a scenario
|
|
*/
|
|
export async function sendTestLogs(
|
|
request: APIRequestContext,
|
|
scenarioId: string,
|
|
logs: unknown[]
|
|
) {
|
|
const response = await request.post(
|
|
`${API_BASE_URL}/scenarios/${scenarioId}/ingest`,
|
|
{
|
|
data: { logs },
|
|
}
|
|
);
|
|
expect(response.ok()).toBeTruthy();
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Generate a unique test scenario name
|
|
*/
|
|
export function generateTestScenarioName(prefix = 'E2E Test'): string {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
return `${prefix} ${timestamp}`;
|
|
}
|
|
|
|
/**
|
|
* Wait for toast notification
|
|
*/
|
|
export async function waitForToast(page: Page, message?: string) {
|
|
const toastSelector = '[data-testid="toast"]'
|
|
+ (message ? `:has-text("${message}")` : '');
|
|
|
|
await page.waitForSelector(toastSelector, { timeout: 10000 });
|
|
}
|
|
|
|
/**
|
|
* Click on a navigation link by label
|
|
*/
|
|
export async function clickNavigation(page: Page, label: string) {
|
|
const navLink = page.locator('nav').getByRole('link', { name: label });
|
|
await navLink.click();
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
|
|
/**
|
|
* Get scenario by name from the scenarios table
|
|
*/
|
|
export async function getScenarioRow(page: Page, scenarioName: string) {
|
|
return page.locator('table tbody tr').filter({ hasText: scenarioName });
|
|
}
|
|
|
|
/**
|
|
* Open scenario actions dropdown
|
|
*/
|
|
export async function openScenarioActions(page: Page, scenarioName: string) {
|
|
const row = await getScenarioRow(page, scenarioName);
|
|
const actionsButton = row.locator('button').first();
|
|
await actionsButton.click();
|
|
return row.locator('[role="menu"]');
|
|
}
|
|
|
|
/**
|
|
* Take a screenshot with a descriptive name
|
|
*/
|
|
export async function takeScreenshot(page: Page, name: string) {
|
|
await page.screenshot({
|
|
path: `e2e/screenshots/${name}.png`,
|
|
fullPage: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if element is visible with retry
|
|
*/
|
|
export async function isElementVisible(
|
|
page: Page,
|
|
selector: string,
|
|
timeout = 5000
|
|
): Promise<boolean> {
|
|
try {
|
|
await page.waitForSelector(selector, { timeout, state: 'visible' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mobile viewport helper
|
|
*/
|
|
export async function setMobileViewport(page: Page) {
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
}
|
|
|
|
/**
|
|
* Tablet viewport helper
|
|
*/
|
|
export async function setTabletViewport(page: Page) {
|
|
await page.setViewportSize({ width: 768, height: 1024 });
|
|
}
|
|
|
|
/**
|
|
* Desktop viewport helper
|
|
*/
|
|
export async function setDesktopViewport(page: Page) {
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
}
|