# Testing Strategy This document defines the testing strategy for FUSION GUI, including the testing pyramid, minimum test requirements for MVP, and details on the fake simulator mode. ## Testing Pyramid ``` /\ / \ / E2E \ <- 2+ tests (Playwright) /------\ / \ / Integration\ <- API route tests (pytest) /--------------\ / \ / Unit Tests \ <- Components + services (Vitest/pytest) /--------------------\ ``` **Test distribution for MVP:** | Layer | Tool | Count (MVP) | Focus | |-------|------|-------------|-------| | Unit (Backend) | pytest | 10+ | RunManager, services, utilities | | Unit (Frontend) | Vitest | 10+ | Components, hooks, utilities | | Integration | pytest | 5+ | API routes with test database | | E2E | Playwright | 2+ | Critical user flows | --- ## Testing Ladder (Frontend) Use the right test type for each scenario: | Level | Tool | When to Use | Speed | |-------|------|-------------|-------| | **Unit** | Vitest | Pure functions, utilities, hooks without DOM | Fast | | **Component** | Vitest + RTL | Isolated component rendering, props, events | Fast | | **Integration** | Vitest + RTL + MSW | Components with API calls, multi-component flows | Medium | | **E2E** | Playwright + fake simulator | Full user journeys, critical paths | Slow | ### PR Test Expectations | PR Type | Required Tests | |---------|----------------| | **UI-only** (new component) | Component test with RTL | | **Backend-only** (new endpoint) | pytest route + service tests | | **API contract change** | Backend pytest + frontend MSW handler update + integration test | | **Bug fix** | Regression test covering the bug | | **Critical flow change** | E2E test update or addition | ### What NOT to Test - Third-party library internals (shadcn/ui, TanStack Query) - Styling details (use visual regression if needed, not unit tests) - Implementation details (test behavior, not internal state) --- ## Accessibility Testing Run accessibility checks as part of E2E tests using `@axe-core/playwright`. ### Setup ```bash cd frontend && npm install -D @axe-core/playwright ``` ### Smoke Test Add to at least one E2E test per major page: ```typescript // frontend/e2e/accessibility.spec.ts import { test, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; test("run list page has no critical a11y violations", async ({ page }) => { await page.goto("/"); const results = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa"]) .analyze(); expect(results.violations.filter((v) => v.impact === "critical")).toEqual([]); }); ``` ### CI Gate Accessibility tests are **advisory in M2**, **blocking in M4**. Critical violations (missing labels, broken focus) should block PRs. --- ## Flake Policy E2E tests can be flaky due to timing, animations, or network variability. **CI Configuration:** - Playwright retries: **1** (not more) - If a test passes only on retry, it is considered a flake **Handling Flakes:** 1. First occurrence: Note in PR, investigate root cause 2. Repeated flakes (2+ in a week): Open a tracking issue labeled `flaky-test` 3. Persistent flakes: Either fix or quarantine the test (skip with `test.skip` + issue link) **Common Fixes:** - Add explicit `waitFor` instead of arbitrary timeouts - Use `toBeVisible()` before interacting - Ensure fake simulator determinism via fixed `FAKE_SIM_DURATION` --- ## Backend Testing (pytest) ### Test Structure ``` fusion/api/tests/ ├── conftest.py # Shared fixtures ├── test_runs.py # Run CRUD and lifecycle ├── test_artifacts.py # Artifact listing and download ├── test_configs.py # Config templates and validation ├── test_health.py # Health endpoint └── test_run_manager.py # RunManager unit tests ``` ### Fixtures (conftest.py) ```python # fusion/api/tests/conftest.py import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from fusion.api.main import app from fusion.api.db.database import get_db, Base from fusion.api.db.models import Run @pytest.fixture def db_session(): """Create an in-memory SQLite database for testing.""" engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) TestingSessionLocal = sessionmaker(bind=engine) session = TestingSessionLocal() try: yield session finally: session.close() @pytest.fixture def client(db_session): """Create a test client with overridden database dependency.""" def override_get_db(): try: yield db_session finally: pass app.dependency_overrides[get_db] = override_get_db with TestClient(app) as c: yield c app.dependency_overrides.clear() @pytest.fixture def sample_run(db_session) -> Run: """Create a sample run for testing.""" run = Run( id="test-run-001", name="Test Run", config_json='{"template": "test"}', status="PENDING", ) db_session.add(run) db_session.commit() return run @pytest.fixture def run_with_artifacts(tmp_path, sample_run): """Create a run with artifact files.""" run_dir = tmp_path / "gui_runs" / sample_run.id output_dir = run_dir / "output" output_dir.mkdir(parents=True) # Create sample artifacts (output_dir / "results.json").write_text('{"blocking_prob": 0.05}') (output_dir / "sim.log").write_text("Simulation started\n") sample_run.output_dir = str(run_dir) return sample_run, run_dir ``` ### Fake Simulator For testing run lifecycle without real simulations, use a fake simulator script. **Important:** The fake simulator lives in `fusion/api/devtools/`, not `fusion/api/tests/`. Test-only folders (`tests/`) may be excluded from wheels and should never be invoked at runtime. The `devtools/` module is packaged and safe for runtime use when `FUSION_GUI_FAKE_SIMULATOR=true`. ```python # fusion/api/devtools/fake_simulator.py #!/usr/bin/env python3 """ Fake simulator for testing. Usage: python fake_simulator.py --config config.ini --output-dir /path/to/output Behavior controlled by environment variables: FAKE_SIM_DURATION: Sleep duration in seconds (default: 0.1) FAKE_SIM_EXIT_CODE: Exit code to return (default: 0) FAKE_SIM_FAIL_AFTER: Fail after N iterations (default: never) """ import argparse import json import os import sys import time from datetime import datetime from pathlib import Path def main(): parser = argparse.ArgumentParser() parser.add_argument("--config", required=True) parser.add_argument("--output-dir", required=True) parser.add_argument("--progress-file", default=None) args = parser.parse_args() duration = float(os.environ.get("FAKE_SIM_DURATION", "0.1")) exit_code = int(os.environ.get("FAKE_SIM_EXIT_CODE", "0")) fail_after = int(os.environ.get("FAKE_SIM_FAIL_AFTER", "-1")) output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) log_file = output_dir.parent / "logs" / "sim.log" log_file.parent.mkdir(parents=True, exist_ok=True) total_iterations = 5 erlangs = [10, 20, 30] with open(log_file, "w") as log: log.write(f"[{datetime.now().isoformat()}] Simulation started\n") log.flush() for erlang in erlangs: for iteration in range(1, total_iterations + 1): # Check for failure trigger total_iters_done = erlangs.index(erlang) * total_iterations + iteration if fail_after > 0 and total_iters_done >= fail_after: log.write(f"[{datetime.now().isoformat()}] ERROR: Simulated failure\n") sys.exit(1) time.sleep(duration / (len(erlangs) * total_iterations)) log.write( f"[{datetime.now().isoformat()}] " f"Erlang {erlang}, Iteration {iteration}/{total_iterations}\n" ) log.flush() # Write progress event if args.progress_file: with open(args.progress_file, "a") as pf: event = { "type": "iteration", "ts": datetime.now().isoformat(), "erlang": erlang, "iteration": iteration, "total_iterations": total_iterations, "metrics": {"blocking_prob": 0.05 * iteration / total_iterations}, } pf.write(json.dumps(event) + "\n") # Write erlang result result_file = output_dir / f"{erlang}_erlang.json" result_file.write_text(json.dumps({ "erlang": erlang, "blocking_prob": 0.05, "iterations": total_iterations, })) log.write(f"[{datetime.now().isoformat()}] Simulation completed\n") sys.exit(exit_code) if __name__ == "__main__": main() ``` ### RunManager Unit Tests ```python # fusion/api/tests/test_run_manager.py import os import sys import pytest from unittest.mock import Mock, patch, MagicMock from pathlib import Path from fusion.api.services.run_manager import RunManager @pytest.fixture def run_manager(tmp_path): """Create RunManager with temp directory.""" return RunManager(runs_dir=tmp_path / "gui_runs") @pytest.fixture def fake_simulator_path(): """Path to fake simulator script (in devtools, not tests).""" return Path(__file__).parent.parent / "devtools" / "fake_simulator.py" class TestRunManager: """Unit tests for RunManager.""" def test_create_run_creates_directory_structure(self, run_manager): """Creating a run should set up the expected directories.""" run_id = run_manager.create_run( name="Test", config={"template": "test"}, ) run_dir = run_manager.runs_dir / run_id assert run_dir.exists() assert (run_dir / "logs").exists() assert (run_dir / "output").exists() assert (run_dir / "config.ini").exists() def test_create_run_writes_config(self, run_manager): """Config should be written to run directory.""" run_id = run_manager.create_run( name="Test", config={"erlang_start": 10, "erlang_end": 50}, ) config_path = run_manager.runs_dir / run_id / "config.ini" assert config_path.exists() content = config_path.read_text() assert "erlang_start" in content or "10" in content @patch("fusion.api.services.run_manager.subprocess.Popen") def test_start_run_launches_subprocess(self, mock_popen, run_manager): """Starting a run should launch simulator subprocess.""" mock_process = MagicMock() mock_process.pid = 12345 mock_popen.return_value = mock_process run_id = run_manager.create_run(name="Test", config={}) run_manager.start_run(run_id) mock_popen.assert_called_once() call_kwargs = mock_popen.call_args.kwargs assert call_kwargs.get("start_new_session", False) or os.name == "nt" @patch("fusion.api.services.run_manager.subprocess.Popen") def test_start_run_stores_pid(self, mock_popen, run_manager, db_session): """PID should be stored for later cancellation.""" mock_process = MagicMock() mock_process.pid = 12345 mock_popen.return_value = mock_process run_id = run_manager.create_run(name="Test", config={}) run_manager.start_run(run_id) # Verify PID is tracked assert run_manager.get_run_pid(run_id) == 12345 def test_cancel_run_not_running_returns_false(self, run_manager): """Cancelling a non-running run should return False.""" run_id = run_manager.create_run(name="Test", config={}) result = run_manager.cancel_run(run_id) assert result is False @pytest.mark.skipif(os.name == "nt", reason="POSIX-specific test") @patch("os.killpg") @patch("os.getpgid") def test_cancel_run_kills_process_group_posix( self, mock_getpgid, mock_killpg, run_manager ): """On POSIX, cancellation should kill the process group.""" import signal mock_getpgid.return_value = 12345 run_id = run_manager.create_run(name="Test", config={}) run_manager._running_processes[run_id] = MagicMock(pid=12345) run_manager.cancel_run(run_id) mock_killpg.assert_called_with(12345, signal.SIGTERM) def test_get_log_path_returns_correct_path(self, run_manager): """Log path should be within run directory.""" run_id = run_manager.create_run(name="Test", config={}) log_path = run_manager.get_log_path(run_id) expected = run_manager.runs_dir / run_id / "logs" / "sim.log" assert log_path == expected class TestRunManagerIntegration: """Integration tests using fake simulator.""" @pytest.mark.slow def test_run_completes_successfully(self, run_manager, fake_simulator_path): """Full run lifecycle with fake simulator.""" with patch.dict(os.environ, {"FAKE_SIM_DURATION": "0.1"}): run_id = run_manager.create_run(name="Test", config={}) run_manager.start_run(run_id, simulator_cmd=[ sys.executable, str(fake_simulator_path) ]) # Wait for completion (with timeout) import time for _ in range(50): # 5 second timeout if run_manager.get_run_status(run_id) == "COMPLETED": break time.sleep(0.1) assert run_manager.get_run_status(run_id) == "COMPLETED" @pytest.mark.slow def test_run_failure_detected(self, run_manager, fake_simulator_path): """Failed runs should be marked as FAILED.""" with patch.dict(os.environ, { "FAKE_SIM_DURATION": "0.1", "FAKE_SIM_EXIT_CODE": "1", }): run_id = run_manager.create_run(name="Test", config={}) run_manager.start_run(run_id, simulator_cmd=[ sys.executable, str(fake_simulator_path) ]) import time for _ in range(50): status = run_manager.get_run_status(run_id) if status in ("COMPLETED", "FAILED"): break time.sleep(0.1) assert run_manager.get_run_status(run_id) == "FAILED" ``` ### API Route Tests ```python # fusion/api/tests/test_runs.py import pytest class TestRunsAPI: """Tests for /api/runs endpoints.""" def test_create_run_returns_201(self, client): """POST /api/runs should return 201 with run ID.""" response = client.post("/api/runs", json={ "name": "Test Run", "template": "default", "config": {}, }) assert response.status_code == 201 assert "id" in response.json() def test_create_run_invalid_template_returns_400(self, client): """Invalid template should return 400.""" response = client.post("/api/runs", json={ "name": "Test", "template": "nonexistent_template", "config": {}, }) assert response.status_code == 400 def test_list_runs_returns_array(self, client): """GET /api/runs should return array.""" response = client.get("/api/runs") assert response.status_code == 200 assert isinstance(response.json(), list) def test_list_runs_with_status_filter(self, client, sample_run): """Status filter should work.""" response = client.get("/api/runs?status=PENDING") assert response.status_code == 200 runs = response.json() assert all(r["status"] == "PENDING" for r in runs) def test_get_run_returns_details(self, client, sample_run): """GET /api/runs/{id} should return run details.""" response = client.get(f"/api/runs/{sample_run.id}") assert response.status_code == 200 assert response.json()["id"] == sample_run.id def test_get_run_not_found(self, client): """Non-existent run should return 404.""" response = client.get("/api/runs/nonexistent") assert response.status_code == 404 def test_delete_run_cancels_if_running(self, client, sample_run, db_session): """DELETE should cancel running run.""" sample_run.status = "RUNNING" db_session.commit() response = client.delete(f"/api/runs/{sample_run.id}") assert response.status_code == 200 # Verify status changed db_session.refresh(sample_run) assert sample_run.status == "CANCELLED" ``` ### Artifact Security Tests ```python # fusion/api/tests/test_artifacts.py import pytest from pathlib import Path class TestArtifactsAPI: """Tests for /api/runs/{id}/artifacts endpoints.""" def test_list_artifacts_returns_files(self, client, run_with_artifacts): """GET /api/runs/{id}/artifacts should list files.""" run, _ = run_with_artifacts response = client.get(f"/api/runs/{run.id}/artifacts") assert response.status_code == 200 artifacts = response.json() assert any(a["name"] == "results.json" for a in artifacts) def test_download_artifact_returns_file(self, client, run_with_artifacts): """GET /api/runs/{id}/artifacts/{path} should return file content.""" run, _ = run_with_artifacts response = client.get(f"/api/runs/{run.id}/artifacts/output/results.json") assert response.status_code == 200 assert response.json()["blocking_prob"] == 0.05 def test_path_traversal_blocked(self, client, run_with_artifacts): """Path traversal attempts should be rejected.""" run, _ = run_with_artifacts # Various traversal attempts traversal_paths = [ "../../../etc/passwd", "..%2F..%2Fetc/passwd", "output/../../../etc/passwd", "/etc/passwd", ] for path in traversal_paths: response = client.get(f"/api/runs/{run.id}/artifacts/{path}") assert response.status_code == 403, f"Path {path} should be blocked" def test_symlink_within_run_allowed(self, client, run_with_artifacts, tmp_path): """Symlinks pointing within run directory should work.""" run, run_dir = run_with_artifacts # Create symlink within run directory link_path = run_dir / "link_to_results.json" link_path.symlink_to(run_dir / "output" / "results.json") response = client.get(f"/api/runs/{run.id}/artifacts/link_to_results.json") assert response.status_code == 200 def test_symlink_escape_blocked(self, client, run_with_artifacts, tmp_path): """Symlinks pointing outside run directory should be rejected.""" run, run_dir = run_with_artifacts # Create file outside run directory external_file = tmp_path / "external_secret.txt" external_file.write_text("secret data") # Create symlink pointing outside escape_link = run_dir / "escape.txt" escape_link.symlink_to(external_file) response = client.get(f"/api/runs/{run.id}/artifacts/escape.txt") assert response.status_code == 403 def test_nonexistent_artifact_returns_404(self, client, run_with_artifacts): """Non-existent file should return 404.""" run, _ = run_with_artifacts response = client.get(f"/api/runs/{run.id}/artifacts/nonexistent.txt") assert response.status_code == 404 ``` --- ## Frontend Testing (Vitest + Testing Library) ### Test Structure ``` frontend/src/ ├── components/ │ ├── runs/ │ │ ├── RunCard.tsx │ │ ├── RunCard.test.tsx │ │ ├── LogViewer.tsx │ │ ├── LogViewer.test.tsx │ │ ├── RunStatusBadge.tsx │ │ └── RunStatusBadge.test.tsx │ └── artifacts/ │ ├── FileBrowser.tsx │ └── FileBrowser.test.tsx ├── hooks/ │ ├── useSSE.ts │ ├── useSSE.test.ts │ ├── useRuns.ts │ └── useRuns.test.ts └── test/ ├── setup.ts # Test setup ├── mocks/ # Mock implementations │ ├── handlers.ts # MSW handlers │ └── server.ts # MSW server └── utils.tsx # Test utilities ``` ### Test Setup ```typescript // frontend/src/test/setup.ts import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach, beforeAll, afterAll } from "vitest"; import { server } from "./mocks/server"; // Start MSW server beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterAll(() => server.close()); afterEach(() => { cleanup(); server.resetHandlers(); }); ``` ```typescript // frontend/src/test/mocks/handlers.ts import { http, HttpResponse } from "msw"; export const handlers = [ // List runs http.get("/api/runs", () => { return HttpResponse.json([ { id: "run-1", name: "Test Run 1", status: "COMPLETED", created_at: "2024-01-01T00:00:00Z", }, { id: "run-2", name: "Test Run 2", status: "RUNNING", created_at: "2024-01-02T00:00:00Z", }, ]); }), // Get single run http.get("/api/runs/:id", ({ params }) => { return HttpResponse.json({ id: params.id, name: `Run ${params.id}`, status: "RUNNING", created_at: "2024-01-01T00:00:00Z", }); }), // Create run http.post("/api/runs", async ({ request }) => { const body = await request.json(); return HttpResponse.json( { id: "new-run-123", ...body, status: "PENDING" }, { status: 201 } ); }), // List artifacts http.get("/api/runs/:id/artifacts", () => { return HttpResponse.json([ { name: "output", type: "directory", size: 0 }, { name: "sim.log", type: "file", size: 1024 }, ]); }), // Health check http.get("/api/health", () => { return HttpResponse.json({ status: "healthy" }); }), ]; ``` ```typescript // frontend/src/test/mocks/server.ts import { setupServer } from "msw/node"; import { handlers } from "./handlers"; export const server = setupServer(...handlers); ``` ### Test Utilities ```text // frontend/src/test/utils.tsx import { ReactElement } from "react"; import { render, RenderOptions } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter } from "react-router-dom"; const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); interface WrapperProps { children: React.ReactNode; } function AllProviders({ children }: WrapperProps) { const queryClient = createTestQueryClient(); return ( {children} ); } const customRender = ( ui: ReactElement, options?: Omit ) => render(ui, { wrapper: AllProviders, ...options }); export * from "@testing-library/react"; export { customRender as render }; ``` ### Component Tests ```typescript // frontend/src/components/runs/RunCard.test.tsx import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "../../test/utils"; import { RunCard } from "./RunCard"; const mockRun = { id: "test-run-1", name: "Test Simulation", status: "RUNNING" as const, created_at: "2024-01-15T10:30:00Z", progress: { current: 5, total: 10 }, }; describe("RunCard", () => { it("renders run name", () => { render(); expect(screen.getByText("Test Simulation")).toBeInTheDocument(); }); it("displays correct status badge", () => { render(); expect(screen.getByText("RUNNING")).toBeInTheDocument(); }); it("shows progress when running", () => { render(); expect(screen.getByRole("progressbar")).toBeInTheDocument(); expect(screen.getByText("50%")).toBeInTheDocument(); }); it("calls onClick when clicked", () => { const onClick = vi.fn(); render(); fireEvent.click(screen.getByRole("article")); expect(onClick).toHaveBeenCalledWith(mockRun.id); }); it("shows cancel button for running runs", () => { const onCancel = vi.fn(); render(); const cancelBtn = screen.getByRole("button", { name: /cancel/i }); fireEvent.click(cancelBtn); expect(onCancel).toHaveBeenCalledWith(mockRun.id); }); it("hides cancel button for completed runs", () => { const completedRun = { ...mockRun, status: "COMPLETED" as const }; render(); expect(screen.queryByRole("button", { name: /cancel/i })).not.toBeInTheDocument(); }); }); ``` ```typescript // frontend/src/components/runs/LogViewer.test.tsx import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "../../test/utils"; import { LogViewer } from "./LogViewer"; // Mock EventSource class MockEventSource { onmessage: ((event: MessageEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; onopen: ((event: Event) => void) | null = null; readyState = 0; constructor(public url: string) { setTimeout(() => { this.readyState = 1; this.onopen?.(new Event("open")); }, 0); } close = vi.fn(); // Test helper to simulate messages simulateMessage(data: string) { this.onmessage?.(new MessageEvent("message", { data })); } simulateError() { this.readyState = 2; this.onerror?.(new Event("error")); } } describe("LogViewer", () => { let mockEventSource: MockEventSource; beforeEach(() => { vi.stubGlobal("EventSource", vi.fn((url: string) => { mockEventSource = new MockEventSource(url); return mockEventSource; })); }); afterEach(() => { vi.unstubAllGlobals(); }); it("connects to SSE endpoint", () => { render(); expect(EventSource).toHaveBeenCalledWith( expect.stringContaining("/api/runs/test-123/logs") ); }); it("displays log lines as they arrive", async () => { render(); await waitFor(() => { mockEventSource.simulateMessage("First log line"); }); expect(screen.getByText("First log line")).toBeInTheDocument(); }); it("auto-scrolls when follow is enabled", async () => { const { container } = render(); const logContainer = container.querySelector("[data-testid='log-container']"); await waitFor(() => { mockEventSource.simulateMessage("New line"); }); // Verify scroll behavior (simplified check) expect(logContainer?.scrollTop).toBeDefined(); }); it("shows reconnecting state on error", async () => { render(); await waitFor(() => { mockEventSource.simulateError(); }); expect(screen.getByText(/reconnecting/i)).toBeInTheDocument(); }); it("closes connection on unmount", () => { const { unmount } = render(); unmount(); expect(mockEventSource.close).toHaveBeenCalled(); }); }); ``` ```typescript // frontend/src/hooks/useSSE.test.ts import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import { useSSE } from "./useSSE"; class MockEventSource { static instances: MockEventSource[] = []; onmessage: ((event: MessageEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; readyState = 1; constructor(public url: string) { MockEventSource.instances.push(this); } close = vi.fn(); } describe("useSSE", () => { beforeEach(() => { MockEventSource.instances = []; vi.stubGlobal("EventSource", MockEventSource); }); afterEach(() => { vi.unstubAllGlobals(); }); it("connects to provided URL", () => { renderHook(() => useSSE("/api/test")); expect(MockEventSource.instances).toHaveLength(1); expect(MockEventSource.instances[0].url).toBe("/api/test"); }); it("returns messages as they arrive", async () => { const { result } = renderHook(() => useSSE("/api/test")); act(() => { MockEventSource.instances[0].onmessage?.( new MessageEvent("message", { data: "test message" }) ); }); await waitFor(() => { expect(result.current.data).toBe("test message"); }); }); it("tracks connection state", async () => { const { result } = renderHook(() => useSSE("/api/test")); expect(result.current.isConnected).toBe(true); act(() => { MockEventSource.instances[0].readyState = 2; MockEventSource.instances[0].onerror?.(new Event("error")); }); await waitFor(() => { expect(result.current.isConnected).toBe(false); }); }); it("reconnects on error with backoff", async () => { vi.useFakeTimers(); renderHook(() => useSSE("/api/test", { reconnect: true })); // Simulate disconnect act(() => { MockEventSource.instances[0].onerror?.(new Event("error")); }); // Fast-forward past reconnect delay await act(async () => { vi.advanceTimersByTime(1000); }); expect(MockEventSource.instances).toHaveLength(2); vi.useRealTimers(); }); it("closes connection on unmount", () => { const { unmount } = renderHook(() => useSSE("/api/test")); const instance = MockEventSource.instances[0]; unmount(); expect(instance.close).toHaveBeenCalled(); }); }); ``` --- ## E2E Testing (Playwright) ### Configuration ```typescript // frontend/playwright.config.ts import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, // Max 1 retry; see Flake Policy workers: process.env.CI ? 1 : undefined, reporter: [["html", { open: "never" }]], use: { baseURL: "http://localhost:8765", trace: "on-first-retry", screenshot: "only-on-failure", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, }, ], webServer: { command: "cd .. && FUSION_GUI_FAKE_SIMULATOR=true fusion gui", url: "http://localhost:8765", reuseExistingServer: !process.env.CI, timeout: 30000, }, }); ``` ### MVP E2E Tests (Required) These two E2E tests are **required for MVP (M2)**: ```typescript // frontend/e2e/create-run.spec.ts import { test, expect } from "@playwright/test"; test.describe("Run Lifecycle", () => { test("user can create, monitor, and cancel a run", async ({ page }) => { // Navigate to home await page.goto("/"); // Click "New Run" button await page.click('button:has-text("New Run")'); // Fill in run details await page.fill('input[name="name"]', "E2E Test Run"); await page.selectOption('select[name="template"]', "default"); // Start the run await page.click('button:has-text("Start")'); // Should redirect to run detail page await expect(page).toHaveURL(/\/runs\/[\w-]+/); // Wait for RUNNING status await expect(page.locator('[data-testid="run-status"]')).toHaveText( "RUNNING", { timeout: 5000 } ); // Verify logs are streaming const logViewer = page.locator('[data-testid="log-viewer"]'); await expect(logViewer).toBeVisible(); // Wait for some log content await expect(logViewer).toContainText("Simulation started", { timeout: 10000, }); // Cancel the run await page.click('button:has-text("Cancel")'); // Confirm cancellation await page.click('button:has-text("Confirm")'); // Verify cancelled status await expect(page.locator('[data-testid="run-status"]')).toHaveText( "CANCELLED", { timeout: 5000 } ); }); test("completed run shows correct status", async ({ page }) => { // Create a run that will complete quickly (fake simulator) await page.goto("/"); await page.click('button:has-text("New Run")'); await page.fill('input[name="name"]', "Quick Run"); await page.selectOption('select[name="template"]', "quick"); await page.click('button:has-text("Start")'); // Wait for completion await expect(page.locator('[data-testid="run-status"]')).toHaveText( "COMPLETED", { timeout: 30000 } ); // Verify final log message await expect(page.locator('[data-testid="log-viewer"]')).toContainText( "Simulation completed" ); }); }); ``` ```typescript // frontend/e2e/view-artifacts.spec.ts import { test, expect } from "@playwright/test"; import { createCompletedRun } from "./helpers"; test.describe("Artifact Management", () => { test("user can browse and download artifacts from completed run", async ({ page, }) => { // Create a completed run (helper uses API directly) const runId = await createCompletedRun(page); // Navigate to run detail await page.goto(`/runs/${runId}`); // Switch to artifacts tab await page.click('[data-testid="tab-artifacts"]'); // Verify artifact list is visible const artifactList = page.locator('[data-testid="artifact-list"]'); await expect(artifactList).toBeVisible(); // Check for expected files await expect(artifactList).toContainText("output"); await expect(artifactList).toContainText("sim.log"); // Navigate into output directory await page.click('text="output"'); // Should see result files await expect(artifactList).toContainText("_erlang.json"); // Download a file const [download] = await Promise.all([ page.waitForEvent("download"), page.click('button:has-text("Download"):near(:text("10_erlang.json"))'), ]); // Verify download started expect(download.suggestedFilename()).toBe("10_erlang.json"); }); test("artifact browser shows file sizes", async ({ page }) => { const runId = await createCompletedRun(page); await page.goto(`/runs/${runId}`); await page.click('[data-testid="tab-artifacts"]'); // Verify file sizes are displayed const fileRow = page.locator('[data-testid="artifact-row"]:has-text("sim.log")'); await expect(fileRow.locator('[data-testid="file-size"]')).toBeVisible(); }); }); ``` ### E2E Test Helpers ```typescript // frontend/e2e/helpers.ts import { Page, expect } from "@playwright/test"; /** * Create a completed run via API and return its ID. * Uses the fake simulator for fast completion. */ export async function createCompletedRun(page: Page): Promise { const response = await page.request.post("/api/runs", { data: { name: "E2E Completed Run", template: "quick", config: {}, }, }); expect(response.ok()).toBeTruthy(); const { id } = await response.json(); // Wait for run to complete (poll status) let status = "PENDING"; let attempts = 0; while (status !== "COMPLETED" && attempts < 60) { await page.waitForTimeout(500); const statusResponse = await page.request.get(`/api/runs/${id}`); const data = await statusResponse.json(); status = data.status; attempts++; if (status === "FAILED") { throw new Error(`Run ${id} failed unexpectedly`); } } if (status !== "COMPLETED") { throw new Error(`Run ${id} did not complete in time`); } return id; } ``` --- ## Fake Simulator Mode The fake simulator enables fast, deterministic testing without running real simulations. ### Activation Set environment variable before starting the server: ```bash FUSION_GUI_FAKE_SIMULATOR=true fusion gui ``` Or in code: ```python # fusion/api/config.py import os class Settings: FAKE_SIMULATOR = os.environ.get("FUSION_GUI_FAKE_SIMULATOR", "").lower() == "true" ``` ### Behavior | Aspect | Real Simulator | Fake Simulator | |--------|---------------|----------------| | Duration | Minutes to hours | 0.1-1 seconds | | Output files | Full simulation data | Minimal stub data | | Progress events | Real metrics | Synthetic values | | Determinism | Varies | Fully deterministic | | Log output | Detailed | Minimal markers | ### Fake Simulator Invocation ```python # fusion/api/services/run_manager.py from fusion.api.config import settings def get_simulator_command(run_id: str, config_path: str) -> list[str]: """Get the command to run the simulator.""" if settings.FAKE_SIMULATOR: return [ sys.executable, str(Path(__file__).parent.parent / "devtools" / "fake_simulator.py"), "--config", config_path, "--output-dir", f"data/gui_runs/{run_id}/output", "--progress-file", f"data/gui_runs/{run_id}/progress.jsonl", ] else: return [ sys.executable, "-m", "fusion.cli.run_sim", "--config", config_path, "--output-dir", f"data/gui_runs/{run_id}/output", ] ``` ### Controlling Fake Simulator Behavior Additional environment variables for testing edge cases: | Variable | Description | Default | |----------|-------------|---------| | `FAKE_SIM_DURATION` | Total runtime in seconds | `0.1` | | `FAKE_SIM_EXIT_CODE` | Exit code (0=success, 1=failure) | `0` | | `FAKE_SIM_FAIL_AFTER` | Fail after N iterations | `-1` (never) | Example: Testing failure handling: ```bash FUSION_GUI_FAKE_SIMULATOR=true \ FAKE_SIM_EXIT_CODE=1 \ pytest fusion/api/tests/test_run_failure.py ``` --- ## Test Commands ### Backend ```bash # Run all backend tests pytest fusion/api/tests/ -v # Run with coverage pytest fusion/api/tests/ --cov=fusion/api --cov-report=html # Run only fast tests (exclude slow integration tests) pytest fusion/api/tests/ -v -m "not slow" # Run specific test file pytest fusion/api/tests/test_runs.py -v ``` ### Frontend ```bash # Run all frontend tests cd frontend && npm test # Run in watch mode cd frontend && npm run test:watch # Run with coverage cd frontend && npm run test:coverage # Run specific test file cd frontend && npm test -- RunCard.test.tsx ``` ### E2E ```bash # Run E2E tests (starts server automatically) cd frontend && npm run test:e2e # Run E2E tests with UI cd frontend && npm run test:e2e:ui # Run specific E2E test cd frontend && npx playwright test create-run.spec.ts # Debug E2E test cd frontend && npx playwright test create-run.spec.ts --debug ``` ### All Tests ```bash # Run all tests (backend + frontend + e2e) make test-gui # CI mode (stricter, no watch) make test-gui-ci ``` --- ## CI Integration See [08-ci-cd.md](08-ci-cd.md) for the full CI pipeline. Key test-related jobs: 1. **Backend Tests**: `pytest fusion/api/tests/ -v --tb=short` 2. **Frontend Tests**: `npm run test:ci` 3. **E2E Tests**: `npm run test:e2e` with `FUSION_GUI_FAKE_SIMULATOR=true` All tests must pass before merging to `develop` or `main`.