diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index c0be2d42..b88ca02e 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,7 @@ on: jobs: claude-review: + if: false # Disabled - using opencode-review.yml instead runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index b809ba3d..9c80033a 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -37,15 +37,32 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 1 + persist-credentials: false - - name: Clear git extraheader to avoid duplicate auth + - name: Clear git credentials to avoid duplicate auth if: | github.event.pull_request.changed_files >= 5 || steps.calc.outputs.total >= 20 run: | + # Clear all GitHub-related git config to prevent auth conflicts git config --global --unset-all http.https://github.com/.extraheader || true git config --local --unset-all http.https://github.com/.extraheader || true git config --global --unset-all credential.helper || true + git config --local --unset-all credential.helper || true + git config --global --unset-all credential."https://github.com".helper || true + git config --local --unset-all credential."https://github.com".helper || true + # Remove any credential URLs + git config --global --unset-all credential.url || true + git config --local --unset-all credential.url || true + # Clear any includeIf configs that might add credentials + # Note: git config doesn't support wildcards, so we iterate over matching keys + # Use case-insensitive grep to catch both "includeIf" and "includeif" + for key in $(git config --global --list --name-only 2>/dev/null | grep -i "^includeif\." || true); do + git config --global --unset "$key" || true + done + for key in $(git config --local --list --name-only 2>/dev/null | grep -i "^includeif\." || true); do + git config --local --unset "$key" || true + done - name: Run OpenCode PR Review # Only review substantial changes (5+ files OR 20+ lines changed) @@ -54,6 +71,7 @@ jobs: steps.calc.outputs.total >= 20 uses: anomalyco/opencode/github@latest env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }} # Pass PR context as environment variables for the review PR_NUMBER: ${{ github.event.pull_request.number }} @@ -62,6 +80,7 @@ jobs: REPO_NAME: ${{ github.repository }} with: model: zai-coding-plan/glm-4.7 + use_github_token: true prompt: | You are reviewing PR #${{ github.event.pull_request.number }} in repository ${{ github.repository }}. diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 8297bc84..553d2fdb 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -717,7 +717,92 @@ test('should show correct state when X already completed', async ({ page }) => { ### State Reconciliation Test Files - `test_state_reconciliation.spec.ts` - Comprehensive state reconciliation tests -- `test_late_joining_user.spec.ts` - Additional late-joining user scenarios +- `test_late_joining_user.spec.ts` - Late-joining user scenarios (may catch WebSocket events) +- `test_returning_user.spec.ts` - Returning user scenarios (no WebSocket events) + +## Returning User vs Late-Joining User + +**Critical distinction** (GitHub Issue #231): + +| Scenario | WebSocket | Data Source | Test Pattern | +|----------|-----------|-------------|--------------| +| **Late-Joining** | May catch some events | API + partial WebSocket | Navigate during active session | +| **Returning User** | No events received | API only | Block WebSocket, navigate to seeded project | + +### The Returning User Problem (Fixed in #231) + +Users who navigate to a project AFTER all events occurred (page refresh, login later, new tab) don't receive WebSocket history. Before the fix: + +```typescript +// OLD BEHAVIOR: Tasks only loaded via WebSocket events +useEffect(() => { + // Intentionally empty - tasks managed via WebSocket +}, [tasksData]); +``` + +After the fix: + +```typescript +// NEW BEHAVIOR: Tasks loaded from API on mount +useEffect(() => { + if (tasksData?.data?.tasks) { + dispatch({ type: 'TASKS_LOADED', payload: tasksData.data.tasks }); + } +}, [tasksData]); +``` + +### Writing Returning User Tests + +Block WebSocket to ensure tests don't rely on real-time events: + +```typescript +import { blockWebSocketConnections } from './test-utils'; + +test('should show state when returning to project', async ({ page }) => { + // Block WebSocket BEFORE navigation + const unblock = await blockWebSocketConnections(page); + + // Navigate as returning user (no WebSocket history) + await page.goto(`${FRONTEND_URL}/projects/${PROJECT_ID}`); + + // Wait for API data to load + await page.waitForLoadState('networkidle'); + + // Verify UI shows correct state from API + await expect(page.locator('[data-testid="task-card"]')).toHaveCount(5); + + // Cleanup + await unblock(); +}); +``` + +### Helper Functions + +Use these utilities from `test-utils.ts`: + +```typescript +// Block WebSocket connections +const unblock = await blockWebSocketConnections(page); + +// Verify task state from API +await verifyTaskStateFromAPI(page, projectId, { + inProgress: 2, + completed: 3, + total: 5, +}); + +// Verify task state from DOM +const { actualCounts, passed, errors } = await verifyTaskStateFromDOM(page, { + inProgress: 2, + completed: 3, +}); + +// Verify project phase +await verifyProjectPhaseFromAPI(page, projectId, 'active'); + +// Verify project completion +const { isComplete, hasActiveWork } = await verifyProjectCompletionFromDOM(page); +``` ### Smoke Tests diff --git a/tests/e2e/test-utils.ts b/tests/e2e/test-utils.ts index 32e1e717..04e14280 100644 --- a/tests/e2e/test-utils.ts +++ b/tests/e2e/test-utils.ts @@ -717,3 +717,238 @@ export async function answerDiscoveryQuestion( // This is a valid end state, so we continue } } + +// ============================================================================ +// STATE VERIFICATION HELPERS (for returning user tests) +// ============================================================================ + +/** + * Expected task state counts by status + */ +export interface ExpectedTaskState { + inProgress?: number; + completed?: number; + pending?: number; + blocked?: number; + total?: number; +} + +/** + * Verify task state from API matches expected counts + * + * Use this to validate that API returns expected task data before checking UI. + * This is critical for returning user tests where WebSocket is blocked. + * + * @param page - Playwright page object + * @param projectId - Project ID to check + * @param expected - Expected task counts by status + * @throws Error if task counts don't match + * + * @example + * await verifyTaskStateFromAPI(page, '3', { + * inProgress: 2, + * completed: 1, + * total: 5 + * }); + */ +export async function verifyTaskStateFromAPI( + page: Page, + projectId: string, + expected: ExpectedTaskState +): Promise<{ tasks: any[]; counts: Required }> { + // Get auth token + const token = await getAuthToken(page); + if (!token) { + throw new Error('No auth token available'); + } + + // Fetch tasks from API + const response = await page.request.get(`${BACKEND_URL}/api/projects/${projectId}/tasks`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok()) { + throw new Error(`Failed to fetch tasks: ${response.status()}`); + } + + const data = await response.json(); + const tasks = data.tasks || []; + + // Count tasks by status + const counts = { + inProgress: tasks.filter((t: { status: string }) => t.status === 'in_progress').length, + completed: tasks.filter((t: { status: string }) => t.status === 'completed').length, + pending: tasks.filter((t: { status: string }) => t.status === 'pending').length, + blocked: tasks.filter((t: { status: string }) => t.status === 'blocked').length, + total: tasks.length, + }; + + // Validate expected counts + if (expected.inProgress !== undefined && counts.inProgress !== expected.inProgress) { + throw new Error(`Expected ${expected.inProgress} in-progress tasks, got ${counts.inProgress}`); + } + if (expected.completed !== undefined && counts.completed !== expected.completed) { + throw new Error(`Expected ${expected.completed} completed tasks, got ${counts.completed}`); + } + if (expected.pending !== undefined && counts.pending !== expected.pending) { + throw new Error(`Expected ${expected.pending} pending tasks, got ${counts.pending}`); + } + if (expected.blocked !== undefined && counts.blocked !== expected.blocked) { + throw new Error(`Expected ${expected.blocked} blocked tasks, got ${counts.blocked}`); + } + if (expected.total !== undefined && counts.total !== expected.total) { + throw new Error(`Expected ${expected.total} total tasks, got ${counts.total}`); + } + + return { tasks, counts }; +} + +/** + * Verify project phase from API + * + * @param page - Playwright page object + * @param projectId - Project ID to check + * @param expectedPhase - Expected phase (discovery, planning, active, review, complete) + * @throws Error if phase doesn't match + */ +export async function verifyProjectPhaseFromAPI( + page: Page, + projectId: string, + expectedPhase: string +): Promise<{ project: any }> { + const token = await getAuthToken(page); + if (!token) { + throw new Error('No auth token available'); + } + + const response = await page.request.get(`${BACKEND_URL}/api/projects/${projectId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok()) { + throw new Error(`Failed to fetch project: ${response.status()}`); + } + + const project = await response.json(); + + if (project.phase !== expectedPhase) { + throw new Error(`Expected project phase '${expectedPhase}', got '${project.phase}'`); + } + + return { project }; +} + +/** + * Verify task state from DOM elements + * + * Checks the actual UI for task status indicators. + * Use after page has loaded to verify UI matches expected state. + * + * @param page - Playwright page object + * @param expected - Expected task counts by status + * @returns Object with actual counts and whether validation passed + */ +export async function verifyTaskStateFromDOM( + page: Page, + expected: ExpectedTaskState +): Promise<{ actualCounts: ExpectedTaskState; passed: boolean; errors: string[] }> { + const errors: string[] = []; + + // Look for task items with status indicators + // Common patterns: data-status, data-task-status, status badge classes + const inProgressLocator = page.locator('[data-status="in_progress"], [data-task-status="in_progress"]'); + const completedLocator = page.locator('[data-status="completed"], [data-task-status="completed"]'); + const pendingLocator = page.locator('[data-status="pending"], [data-task-status="pending"]'); + const blockedLocator = page.locator('[data-status="blocked"], [data-task-status="blocked"]'); + const allTasksLocator = page.locator('[data-testid="task-item"], [data-testid="task-card"]'); + + const actualCounts: ExpectedTaskState = { + inProgress: await inProgressLocator.count(), + completed: await completedLocator.count(), + pending: await pendingLocator.count(), + blocked: await blockedLocator.count(), + total: await allTasksLocator.count(), + }; + + // Validate expected counts + if (expected.inProgress !== undefined && actualCounts.inProgress !== expected.inProgress) { + errors.push(`Expected ${expected.inProgress} in-progress tasks in DOM, found ${actualCounts.inProgress}`); + } + if (expected.completed !== undefined && actualCounts.completed !== expected.completed) { + errors.push(`Expected ${expected.completed} completed tasks in DOM, found ${actualCounts.completed}`); + } + if (expected.pending !== undefined && actualCounts.pending !== expected.pending) { + errors.push(`Expected ${expected.pending} pending tasks in DOM, found ${actualCounts.pending}`); + } + if (expected.blocked !== undefined && actualCounts.blocked !== expected.blocked) { + errors.push(`Expected ${expected.blocked} blocked tasks in DOM, found ${actualCounts.blocked}`); + } + if (expected.total !== undefined && actualCounts.total !== expected.total) { + errors.push(`Expected ${expected.total} total tasks in DOM, found ${actualCounts.total}`); + } + + return { actualCounts, passed: errors.length === 0, errors }; +} + +/** + * Verify project completion state from UI + * + * Checks for completion indicators in the UI. + * + * @param page - Playwright page object + * @returns Object with completion state details + */ +export async function verifyProjectCompletionFromDOM( + page: Page +): Promise<{ isComplete: boolean; hasActiveWork: boolean; details: string }> { + // Check for completion badge/status + const statusBadge = page.locator('[data-testid="project-status"], [data-testid="phase-badge"]'); + let statusText = ''; + if (await statusBadge.first().isVisible()) { + statusText = await statusBadge.first().textContent() || ''; + } + + const isComplete = /complete|done|finished/i.test(statusText); + + // Check for any in-progress or pending tasks + const inProgressTasks = await page.locator('[data-status="in_progress"]').count(); + const pendingTasks = await page.locator('[data-status="pending"]').count(); + const hasActiveWork = inProgressTasks > 0 || pendingTasks > 0; + + return { + isComplete, + hasActiveWork, + details: `Status: "${statusText}", In-progress: ${inProgressTasks}, Pending: ${pendingTasks}`, + }; +} + +/** + * Block WebSocket connections to simulate returning user scenario + * + * CRITICAL: Call this BEFORE navigating to the project page. + * Returns a cleanup function to restore WebSocket connections. + * + * @param page - Playwright page object + * @returns Cleanup function to unblock WebSocket + * + * @example + * const unblock = await blockWebSocketConnections(page); + * await page.goto('/projects/3'); + * // ... test assertions ... + * await unblock(); + */ +export async function blockWebSocketConnections(page: Page): Promise<() => Promise> { + // Block all WebSocket upgrade requests + await page.route('**/ws**', async (route) => { + await route.abort('connectionrefused'); + }); + + await page.route('**/localhost**/ws**', async (route) => { + await route.abort('connectionrefused'); + }); + + return async () => { + await page.unroute('**/ws**'); + await page.unroute('**/localhost**/ws**'); + }; +} diff --git a/tests/e2e/test_returning_user.spec.ts b/tests/e2e/test_returning_user.spec.ts new file mode 100644 index 00000000..a49d0ed3 --- /dev/null +++ b/tests/e2e/test_returning_user.spec.ts @@ -0,0 +1,538 @@ +/** + * E2E tests for Returning User scenarios + * + * These tests validate that users who navigate to a project AFTER missing all + * WebSocket events still see the correct UI state. This is fundamentally different + * from "late-joining user" tests which may catch some WebSocket events. + * + * Key testing strategy: + * 1. Block WebSocket connections to force API-only state loading + * 2. Navigate to projects with seeded state (in-progress, completed, etc.) + * 3. Verify UI displays correct state from API endpoints, not WebSocket events + * + * Seeded test projects (from seed-test-data.py): + * - Project 3: 'active' phase with in-progress tasks (E2E_TEST_PROJECT_ACTIVE_ID) + * - Project 4: 'review' phase with quality gate findings (E2E_TEST_PROJECT_REVIEW_ID) + * - Project 5: 'completed' phase with all tasks done (E2E_TEST_PROJECT_COMPLETED_ID) + * + * See: GitHub Issue #231 - E2E test failures for returning user state reconciliation + */ + +import { test, expect, Page, APIRequestContext } from '@playwright/test'; +import { + loginUser, + setupErrorMonitoring, + checkTestErrors, + ExtendedPage, + blockWebSocketConnections, +} from './test-utils'; + +const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3001'; +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8080'; + +// Project IDs from seed-test-data.py (these are the actual seeded IDs) +const ACTIVE_PROJECT_ID = process.env.E2E_TEST_PROJECT_ACTIVE_ID || '3'; +const COMPLETED_PROJECT_ID = process.env.E2E_TEST_PROJECT_COMPLETED_ID || '5'; +const REVIEW_PROJECT_ID = process.env.E2E_TEST_PROJECT_REVIEW_ID || '4'; + +/** + * Helper to get an authenticated API request context + */ +async function getAuthenticatedRequest(page: Page): Promise<{ request: APIRequestContext; token: string }> { + const response = await page.request.post(`${BACKEND_URL}/auth/jwt/login`, { + form: { + username: 'test@example.com', + password: 'Testpassword123', + }, + }); + + if (!response.ok()) { + throw new Error(`Failed to login: ${response.status()} ${response.statusText()}`); + } + + const data = await response.json(); + return { request: page.request, token: data.access_token }; +} + +/** + * Verify task counts from API match expected state + */ +async function verifyTaskState( + request: APIRequestContext, + token: string, + projectId: string, + expectedState: { + inProgress?: number; + completed?: number; + pending?: number; + blocked?: number; + total?: number; + } +): Promise { + const response = await request.get(`${BACKEND_URL}/api/projects/${projectId}/tasks`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok()) { + throw new Error(`Failed to fetch tasks: ${response.status()}`); + } + + const data = await response.json(); + const tasks = data.tasks || []; + + // Count tasks by status + const statusCounts = { + inProgress: tasks.filter((t: { status: string }) => t.status === 'in_progress').length, + completed: tasks.filter((t: { status: string }) => t.status === 'completed').length, + pending: tasks.filter((t: { status: string }) => t.status === 'pending').length, + blocked: tasks.filter((t: { status: string }) => t.status === 'blocked').length, + total: tasks.length, + }; + + console.log(`📊 Task counts: ${JSON.stringify(statusCounts)}`); + + if (expectedState.inProgress !== undefined) { + expect(statusCounts.inProgress).toBe(expectedState.inProgress); + } + if (expectedState.completed !== undefined) { + expect(statusCounts.completed).toBe(expectedState.completed); + } + if (expectedState.pending !== undefined) { + expect(statusCounts.pending).toBe(expectedState.pending); + } + if (expectedState.blocked !== undefined) { + expect(statusCounts.blocked).toBe(expectedState.blocked); + } + if (expectedState.total !== undefined) { + expect(statusCounts.total).toBe(expectedState.total); + } +} + +/** + * Verify project phase from API + */ +async function verifyProjectPhase( + request: APIRequestContext, + token: string, + projectId: string, + expectedPhase: string +): Promise { + const response = await request.get(`${BACKEND_URL}/api/projects/${projectId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok()) { + throw new Error(`Failed to fetch project: ${response.status()}`); + } + + const project = await response.json(); + console.log(`📊 Project phase: ${project.phase}, status: ${project.status}`); + expect(project.phase).toBe(expectedPhase); +} + +test.describe('Returning User Scenarios', () => { + let page: Page; + + test.beforeEach(async ({ page: testPage }) => { + page = testPage; + + // Setup error monitoring + const errorMonitor = setupErrorMonitoring(page); + (page as ExtendedPage).__errorMonitor = errorMonitor; + + // Login using real authentication flow + await loginUser(page); + console.log('✅ Logged in successfully'); + }); + + test.afterEach(async ({ page }) => { + checkTestErrors(page, 'Returning User test', [ + 'net::ERR_ABORTED', + 'Failed to fetch RSC payload', + 'WebSocket', // Expected since we block WebSocket + 'connectionrefused', + ]); + }); + + test.describe('In-Progress Project State (Project 3)', () => { + /** + * CRITICAL TEST: Returning user sees in-progress tasks correctly + * + * Scenario: + * 1. Project has 2 in-progress tasks, 1 completed, 1 blocked, 1 pending + * 2. User navigates to project WITHOUT WebSocket connection + * 3. UI should display correct task counts from API data + * + * This tests the agentStateSync.fullStateResync() code path. + */ + test('should show in-progress tasks when user returns to active project @smoke @returning-user', async () => { + const { request, token } = await getAuthenticatedRequest(page); + + // First, verify project state matches expected seed data + await verifyProjectPhase(request, token, ACTIVE_PROJECT_ID, 'active'); + await verifyTaskState(request, token, ACTIVE_PROJECT_ID, { + inProgress: 2, + completed: 1, + blocked: 1, + pending: 1, + total: 5, + }); + + // Block WebSocket to simulate returning user (missed all events) + const unblock = await blockWebSocketConnections(page); + console.log('🔒 WebSocket blocked - simulating returning user'); + + // Navigate to project as a "returning user" (no WebSocket history) + await page.goto(`${FRONTEND_URL}/projects/${ACTIVE_PROJECT_ID}`); + await page.waitForLoadState('networkidle'); + + // Wait for dashboard to load + await page.locator('[data-testid="dashboard-header"]').waitFor({ + state: 'visible', + timeout: 15000, + }); + console.log('✅ Dashboard loaded'); + + // Click on Tasks tab to see task list + const tasksTab = page.locator('[data-testid="tasks-tab"]'); + await tasksTab.click(); + await page.waitForTimeout(500); + + // Wait for tasks panel to be visible + const tasksPanel = page.locator('[data-testid="tasks-panel"]'); + await expect(tasksPanel).toBeVisible({ timeout: 10000 }); + console.log('✅ Tasks panel visible'); + + // Verify in-progress tasks are displayed + // The seed data has 2 in-progress tasks for Project 3 + const inProgressTasks = page.locator('[data-status="in_progress"], [data-task-status="in_progress"]'); + const inProgressCount = await inProgressTasks.count(); + console.log(`📊 In-progress tasks visible in UI: ${inProgressCount}`); + + // Verify total task count matches API (5 tasks seeded for Project 3) + // Using task-card since TaskList renders li elements with data-testid="task-card" + const allTasks = page.locator('[data-testid="task-card"]'); + const totalTasksVisible = await allTasks.count(); + console.log(`📊 Total tasks visible in UI: ${totalTasksVisible}`); + + // ASSERTION: All 5 seeded tasks should be visible (matching API verification above) + // The TaskList component shows all tasks by default with "All" filter + expect(totalTasksVisible).toBe(5); + + // Verify project phase badge shows correct state + // Look for active/development indicators + const phaseIndicators = page.locator('[data-testid="project-status"], [data-testid="phase-badge"]'); + if (await phaseIndicators.first().isVisible()) { + const phaseText = await phaseIndicators.first().textContent(); + console.log(`📊 Phase indicator: ${phaseText}`); + } + + // Unblock WebSocket for cleanup + await unblock(); + console.log('✅ Test passed: Returning user sees in-progress tasks correctly'); + }); + + test('should show agent status when returning to project with working agents', async () => { + const { request, token } = await getAuthenticatedRequest(page); + + // Block WebSocket to simulate returning user + const unblock = await blockWebSocketConnections(page); + + // Navigate to active project + await page.goto(`${FRONTEND_URL}/projects/${ACTIVE_PROJECT_ID}`); + await page.waitForLoadState('networkidle'); + + // Wait for dashboard to load + await page.locator('[data-testid="dashboard-header"]').waitFor({ + state: 'visible', + timeout: 15000, + }); + + // Check for agent status panel on Overview tab + const agentPanel = page.locator('[data-testid="agent-status-panel"], [data-testid="agent-state-panel"]'); + const panelVisible = await agentPanel.first().isVisible().catch(() => false); + + if (panelVisible) { + console.log('✅ Agent status panel is visible'); + // Agent state should be loaded from API + const agentCards = page.locator('[data-testid="agent-card"], [data-testid="agent-item"]'); + const agentCount = await agentCards.count(); + console.log(`📊 Agent cards visible: ${agentCount}`); + } else { + console.log('â„šī¸ Agent status panel not visible on this tab - checking API'); + // Verify agents via API + const agentResponse = await request.get(`${BACKEND_URL}/api/projects/${ACTIVE_PROJECT_ID}/agents`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (agentResponse.ok()) { + const agentData = await agentResponse.json(); + console.log(`📊 Agents from API: ${JSON.stringify(agentData)}`); + } + } + + await unblock(); + console.log('✅ Test passed: Agent state accessible for returning user'); + }); + }); + + test.describe('Completed Project State (Project 5)', () => { + /** + * CRITICAL TEST: Returning user sees completed project state correctly + * + * Scenario: + * 1. All tasks are completed with passed quality gates + * 2. Project phase is 'complete' + * 3. User navigates to project WITHOUT WebSocket connection + * 4. UI should display 100% completion, no active work + */ + test('should show completed state when user returns to finished project @smoke @returning-user', async () => { + const { request, token } = await getAuthenticatedRequest(page); + + // First, verify project state matches expected seed data + await verifyProjectPhase(request, token, COMPLETED_PROJECT_ID, 'complete'); + await verifyTaskState(request, token, COMPLETED_PROJECT_ID, { + completed: 5, + total: 5, + }); + + // Block WebSocket to simulate returning user + const unblock = await blockWebSocketConnections(page); + console.log('🔒 WebSocket blocked - simulating returning user'); + + // Navigate to completed project + await page.goto(`${FRONTEND_URL}/projects/${COMPLETED_PROJECT_ID}`); + await page.waitForLoadState('networkidle'); + + // Wait for dashboard to load + await page.locator('[data-testid="dashboard-header"]').waitFor({ + state: 'visible', + timeout: 15000, + }); + console.log('✅ Dashboard loaded'); + + // Look for completion indicators + // The project phase should show as 'complete' or 'completed' + const statusBadge = page.locator('[data-testid="project-status"], [data-testid="phase-badge"]'); + if (await statusBadge.first().isVisible()) { + const statusText = await statusBadge.first().textContent(); + console.log(`📊 Project status: ${statusText}`); + // Status should indicate completion + expect(statusText?.toLowerCase()).toMatch(/complete|done|finished/i); + } + + // Click on Tasks tab to verify all tasks completed + const tasksTab = page.locator('[data-testid="tasks-tab"]'); + await tasksTab.click(); + await page.waitForTimeout(500); + + // Wait for tasks panel + const tasksPanel = page.locator('[data-testid="tasks-panel"]'); + await expect(tasksPanel).toBeVisible({ timeout: 10000 }); + + // Check for task completion indicators + const completedTasks = page.locator('[data-status="completed"], [data-task-status="completed"]'); + const completedCount = await completedTasks.count(); + console.log(`📊 Completed tasks visible: ${completedCount}`); + + // All tasks should be completed (no in-progress or pending) + const inProgressTasks = page.locator('[data-status="in_progress"], [data-task-status="in_progress"]'); + const inProgressCount = await inProgressTasks.count(); + expect(inProgressCount).toBe(0); + + const pendingTasks = page.locator('[data-status="pending"], [data-task-status="pending"]'); + const pendingCount = await pendingTasks.count(); + expect(pendingCount).toBe(0); + + console.log('✅ No in-progress or pending tasks visible (correct for completed project)'); + + // Verify no active agents (all should be idle) + const agentResponse = await request.get(`${BACKEND_URL}/api/projects/${COMPLETED_PROJECT_ID}/agents`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (agentResponse.ok()) { + const agents = await agentResponse.json(); + // For completed projects, agents should all be inactive + console.log(`📊 Agents for completed project: ${JSON.stringify(agents)}`); + } + + await unblock(); + console.log('✅ Test passed: Returning user sees completed state correctly'); + }); + + test('should show quality gates as passed for completed project', async () => { + // Authenticate but only need loginUser for this test (no direct API calls needed) + await getAuthenticatedRequest(page); + + // Block WebSocket + const unblock = await blockWebSocketConnections(page); + + // Navigate to completed project + await page.goto(`${FRONTEND_URL}/projects/${COMPLETED_PROJECT_ID}`); + await page.waitForLoadState('networkidle'); + + // Wait for dashboard + await page.locator('[data-testid="dashboard-header"]').waitFor({ + state: 'visible', + timeout: 15000, + }); + + // Click on Quality Gates tab + const qualityTab = page.locator('[data-testid="quality-gates-tab"]'); + await qualityTab.click(); + await page.waitForTimeout(500); + + // Wait for quality gates panel + const qualityPanel = page.locator('[data-testid="quality-gates-panel"]'); + await expect(qualityPanel).toBeVisible({ timeout: 10000 }); + + // For completed project, all quality gates should show as passed + // Check for any failure indicators + const failedGates = page.locator('[data-gate-status="failed"], [data-quality-status="failed"]'); + const failedCount = await failedGates.count(); + console.log(`📊 Failed quality gates: ${failedCount}`); + + // For Project 5 (completed), all gates should be passed (seed data has 5 tasks with 'passed' quality_gate_status) + expect(failedCount).toBe(0); + + await unblock(); + console.log('✅ Test passed: Quality gates show as passed for completed project'); + }); + }); + + test.describe('Review Phase State (Project 4)', () => { + /** + * TEST: Returning user sees quality gate failures in review phase + * + * Scenario: + * 1. Project has completed tasks but some with failed quality gates + * 2. Code review findings exist + * 3. User navigates to project WITHOUT WebSocket connection + * 4. UI should display review findings and failed gates + */ + test('should show quality gate failures when returning to project in review @returning-user', async () => { + const { request, token } = await getAuthenticatedRequest(page); + + // Verify project is in review phase + await verifyProjectPhase(request, token, REVIEW_PROJECT_ID, 'review'); + + // Block WebSocket + const unblock = await blockWebSocketConnections(page); + console.log('🔒 WebSocket blocked - simulating returning user'); + + // Navigate to review project + await page.goto(`${FRONTEND_URL}/projects/${REVIEW_PROJECT_ID}`); + await page.waitForLoadState('networkidle'); + + // Wait for dashboard + await page.locator('[data-testid="dashboard-header"]').waitFor({ + state: 'visible', + timeout: 15000, + }); + console.log('✅ Dashboard loaded'); + + // Click on Quality Gates tab + const qualityTab = page.locator('[data-testid="quality-gates-tab"]'); + await qualityTab.click(); + await page.waitForTimeout(500); + + // Wait for quality gates panel + const qualityPanel = page.locator('[data-testid="quality-gates-panel"]'); + await expect(qualityPanel).toBeVisible({ timeout: 10000 }); + + // Project 4 has tasks with failed quality gates (seed data) + // Check for failure indicators + const qualityIndicators = page.locator('[data-testid="quality-gate-status"], [data-testid="gate-result"]'); + const indicatorCount = await qualityIndicators.count(); + console.log(`📊 Quality gate indicators: ${indicatorCount}`); + + // Verify API returns tasks (quality gate status may not be exposed in all API responses) + const tasksResponse = await request.get(`${BACKEND_URL}/api/projects/${REVIEW_PROJECT_ID}/tasks`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (tasksResponse.ok()) { + const tasksData = await tasksResponse.json(); + const tasks = tasksData.tasks || []; + console.log(`📊 Tasks returned from API: ${tasks.length}`); + // Project 4 should have 4 tasks seeded + expect(tasks.length).toBeGreaterThan(0); + + // Check if quality_gate_status field is exposed (optional assertion) + const tasksWithQualityGate = tasks.filter((t: { quality_gate_status?: string }) => t.quality_gate_status); + console.log(`📊 Tasks with quality_gate_status field: ${tasksWithQualityGate.length}`); + + // Log fields available on tasks for debugging + if (tasks.length > 0) { + console.log(`📊 Available task fields: ${Object.keys(tasks[0]).join(', ')}`); + } + } + + // Click on Tasks tab to see review findings + const tasksTab = page.locator('[data-testid="tasks-tab"]'); + await tasksTab.click(); + await page.waitForTimeout(500); + + // Check for review findings panel + const reviewPanel = page.locator('[data-testid="review-findings-panel"]'); + if (await reviewPanel.isVisible()) { + console.log('✅ Review findings panel is visible'); + } + + await unblock(); + console.log('✅ Test passed: Returning user sees quality gate failures correctly'); + }); + }); + + test.describe('State Reconciliation Verification', () => { + /** + * TEST: Verify fullStateResync loads complete state from API + * + * This test explicitly verifies that the agentStateSync.fullStateResync() + * function correctly fetches and populates state when WebSocket is unavailable. + */ + test('should load complete state from API endpoints without WebSocket @returning-user', async () => { + // Authenticate (sets up session for frontend API calls) + await getAuthenticatedRequest(page); + + // Block WebSocket BEFORE navigation + const unblock = await blockWebSocketConnections(page); + console.log('🔒 WebSocket blocked before navigation'); + + // Navigate to active project + await page.goto(`${FRONTEND_URL}/projects/${ACTIVE_PROJECT_ID}`); + + // Wait for all critical API calls to complete + const apiCalls = await Promise.all([ + page.waitForResponse((r) => r.url().includes(`/api/projects/${ACTIVE_PROJECT_ID}`)), + page.waitForResponse((r) => r.url().includes('/api/projects') && r.url().includes('/tasks')), + page.waitForResponse((r) => r.url().includes('/api/projects') && r.url().includes('/agents')), + ].map(p => p.catch(() => null))); + + const successfulCalls = apiCalls.filter(r => r !== null); + console.log(`📊 API calls completed: ${successfulCalls.length}/3`); + + // Wait for dashboard + await page.locator('[data-testid="dashboard-header"]').waitFor({ + state: 'visible', + timeout: 15000, + }); + + // Verify dashboard shows project name (from API) + const projectName = page.locator('[data-testid="project-name"], [data-testid="project-selector"] h1'); + if (await projectName.first().isVisible()) { + const nameText = await projectName.first().textContent(); + console.log(`📊 Project name displayed: ${nameText}`); + expect(nameText).toBeTruthy(); + } + + // Verify no "loading" spinners stuck on screen (state fully loaded) + await page.waitForTimeout(2000); // Allow time for async state updates + const loadingSpinners = page.locator('.animate-spin, [data-loading="true"]'); + const spinnerCount = await loadingSpinners.count(); + console.log(`📊 Loading spinners still visible: ${spinnerCount}`); + // Some spinners might be for real-time updates, but main content should be loaded + + await unblock(); + console.log('✅ Test passed: State loaded from API without WebSocket'); + }); + }); +}); diff --git a/web-ui/__tests__/reducers/agentReducer.test.ts b/web-ui/__tests__/reducers/agentReducer.test.ts index b6d3c4a6..540fc47c 100644 --- a/web-ui/__tests__/reducers/agentReducer.test.ts +++ b/web-ui/__tests__/reducers/agentReducer.test.ts @@ -24,6 +24,7 @@ import { import type { AgentAction, AgentsLoadedAction, + TasksLoadedAction, AgentCreatedAction, AgentUpdatedAction, AgentRetiredAction, @@ -100,6 +101,85 @@ describe('agentReducer', () => { }); }); + // ============================================================================ + // TASKS_LOADED Action Tests (Returning User State Reconciliation) + // See: GitHub Issue #231 - E2E test failures for returning user state reconciliation + // ============================================================================ + describe('TASKS_LOADED', () => { + it('should load initial tasks into empty state', () => { + const initialState = getInitialState(); + const tasks = [ + createMockTask({ id: 1, title: 'Task 1' }), + createMockTask({ id: 2, title: 'Task 2' }), + ]; + + const action: TasksLoadedAction = { + type: 'TASKS_LOADED', + payload: tasks, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.tasks).toEqual(tasks); + expect(newState.tasks.length).toBe(2); + expect(newState).not.toBe(initialState); // Immutability check + }); + + it('should replace existing tasks when loading', () => { + const initialState = createInitialAgentState({ + tasks: [createMockTask({ id: 100, title: 'Old task' })], + }); + + const newTasks = [ + createMockTask({ id: 1, title: 'New task 1' }), + createMockTask({ id: 2, title: 'New task 2' }), + ]; + + const action: TasksLoadedAction = { + type: 'TASKS_LOADED', + payload: newTasks, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.tasks).toEqual(newTasks); + expect(newState.tasks.length).toBe(2); + expect(newState.tasks.find((t) => t.id === 100)).toBeUndefined(); + }); + + it('should handle loading empty task array', () => { + const initialState = createInitialAgentState({ + tasks: [createMockTask()], + }); + + const action: TasksLoadedAction = { + type: 'TASKS_LOADED', + payload: [], + }; + + const newState = agentReducer(initialState, action); + + expect(newState.tasks).toEqual([]); + expect(newState.tasks.length).toBe(0); + }); + + it('should not mutate original state', () => { + const originalTasks = [createMockTask({ id: 1 })]; + const initialState = createInitialAgentState({ tasks: originalTasks }); + const tasksSnapshot = [...originalTasks]; + + const action: TasksLoadedAction = { + type: 'TASKS_LOADED', + payload: [createMockTask({ id: 2 })], + }; + + agentReducer(initialState, action); + + // Original tasks array should be unchanged + expect(initialState.tasks).toEqual(tasksSnapshot); + }); + }); + // ============================================================================ // T006: AGENT_CREATED Action Tests // ============================================================================ diff --git a/web-ui/src/components/AgentStateProvider.tsx b/web-ui/src/components/AgentStateProvider.tsx index 0de363b9..827c4fc4 100644 --- a/web-ui/src/components/AgentStateProvider.tsx +++ b/web-ui/src/components/AgentStateProvider.tsx @@ -19,7 +19,8 @@ import { agentsApi, tasksApi, activityApi } from '@/lib/api'; import { getWebSocketClient } from '@/lib/websocket'; import { processWebSocketMessage } from '@/lib/websocketMessageMapper'; import { fullStateResyncWithRetry } from '@/lib/agentStateSync'; -import type { Agent, ActivityItem } from '@/types/agentState'; +import type { Agent, ActivityItem, Task } from '@/types/agentState'; +import { isValidTaskResponse, transformAPITask } from '@/types/agentState'; /** * Props for AgentStateProvider @@ -118,14 +119,35 @@ export function AgentStateProvider({ /** * Load tasks when data is fetched - * - * Note: Tasks don't have a dedicated TASKS_LOADED action. - * We skip loading tasks separately to avoid complexity. - * Tasks will be loaded via WebSocket updates or can be accessed via SWR directly. + * + * CRITICAL: This ensures returning users see tasks even without WebSocket events. + * Without this, users who navigate to a project after missing WebSocket events + * would see an empty task list. + * + * See: GitHub Issue #231 - E2E test failures for returning user state reconciliation */ useEffect(() => { - // Intentionally empty - tasks are managed via WebSocket or fetched on-demand - // This prevents infinite loops and simplifies the data flow + // Dispatch when tasksData.data exists, even if tasks is null/undefined + // This ensures stale tasks are cleared when API returns no tasks + if (tasksData?.data) { + const rawTasks = Array.isArray(tasksData.data.tasks) ? tasksData.data.tasks : []; + + // Validate and transform API tasks to internal Task type + const validTasks: Task[] = rawTasks + .filter((task: unknown) => { + if (!isValidTaskResponse(task)) { + console.warn('Invalid task response skipped:', task); + return false; + } + return true; + }) + .map((task: unknown) => transformAPITask(task as Parameters[0])); + + dispatch({ + type: 'TASKS_LOADED', + payload: validTasks, + }); + } }, [tasksData]); /** diff --git a/web-ui/src/components/Dashboard.tsx b/web-ui/src/components/Dashboard.tsx index 87bad986..8494fb34 100644 --- a/web-ui/src/components/Dashboard.tsx +++ b/web-ui/src/components/Dashboard.tsx @@ -356,7 +356,11 @@ export default function Dashboard({ projectId }: DashboardProps) { CodeFRAME - {projectData.name}
- + {projectData.status.toUpperCase()} {/* Connection status from AgentStateProvider */} diff --git a/web-ui/src/components/TaskList.tsx b/web-ui/src/components/TaskList.tsx index 15f2905f..d95f47bc 100644 --- a/web-ui/src/components/TaskList.tsx +++ b/web-ui/src/components/TaskList.tsx @@ -29,9 +29,11 @@ interface FilterConfig { const FILTER_OPTIONS: FilterConfig[] = [ { label: 'All', status: 'all' }, { label: 'Pending', status: 'pending' }, + { label: 'Assigned', status: 'assigned' }, { label: 'In Progress', status: 'in_progress' }, { label: 'Blocked', status: 'blocked' }, { label: 'Completed', status: 'completed' }, + { label: 'Failed', status: 'failed' }, ]; /** @@ -45,6 +47,10 @@ function getStatusStyles(status: TaskStatus): { bgClass: string; textClass: stri return { bgClass: 'bg-primary/10', textClass: 'text-primary' }; case 'blocked': return { bgClass: 'bg-destructive/10', textClass: 'text-destructive' }; + case 'failed': + return { bgClass: 'bg-destructive/20', textClass: 'text-destructive' }; + case 'assigned': + return { bgClass: 'bg-accent', textClass: 'text-accent-foreground' }; case 'pending': default: return { bgClass: 'bg-muted', textClass: 'text-muted-foreground' }; @@ -81,6 +87,7 @@ const TaskCard = memo(function TaskCard({
  • {/* Task Header */} @@ -182,9 +189,11 @@ const TaskList = memo(function TaskList({ projectId }: TaskListProps) { const counts: Record = { all: projectTasks.length, pending: 0, + assigned: 0, in_progress: 0, blocked: 0, completed: 0, + failed: 0, }; projectTasks.forEach((task) => { diff --git a/web-ui/src/reducers/agentReducer.ts b/web-ui/src/reducers/agentReducer.ts index 80f52a35..3820378a 100644 --- a/web-ui/src/reducers/agentReducer.ts +++ b/web-ui/src/reducers/agentReducer.ts @@ -99,6 +99,18 @@ export function agentReducer( break; } + // ======================================================================== + // TASKS_LOADED - Load initial tasks from API + // Enables returning users to see tasks without WebSocket events + // ======================================================================== + case 'TASKS_LOADED': { + newState = { + ...state, + tasks: action.payload, + }; + break; + } + // ======================================================================== // T022: AGENT_CREATED - Add new agent // ======================================================================== diff --git a/web-ui/src/types/agentState.ts b/web-ui/src/types/agentState.ts index f181e7b3..0c3e7209 100644 --- a/web-ui/src/types/agentState.ts +++ b/web-ui/src/types/agentState.ts @@ -39,12 +39,15 @@ export type AgentMaturity = /** * Task execution status + * Must match TaskStatus in web-ui/src/types/index.ts */ export type TaskStatus = | 'pending' // Not started, no blockers + | 'assigned' // Assigned to agent but not started | 'in_progress' // Agent actively working | 'blocked' // Waiting on dependencies - | 'completed'; // Finished + | 'completed' // Finished successfully + | 'failed'; // Failed to complete /** * Activity feed event categories @@ -117,6 +120,98 @@ export interface Task { timestamp: number; // Unix ms from backend } +/** + * Raw task data from API response + * + * Used for type-safe parsing of API responses before transforming to Task. + * Matches the backend's task serialization format. + */ +export interface APITaskResponse { + id: number; + project_id: number; + title: string; + status: string; // Raw status string from API + assigned_to?: string; // Backend uses assigned_to, not agent_id + depends_on?: string; // Comma-separated task IDs + progress?: number; + timestamp?: number; // May be missing from API + // Additional fields from backend (optional) + task_number?: string; + description?: string; + priority?: number; + workflow_step?: number; + created_at?: string; + completed_at?: string; +} + +/** + * Validates that a raw API response has required Task fields + */ +export function isValidTaskResponse(task: unknown): task is APITaskResponse { + if (typeof task !== 'object' || task === null) return false; + const t = task as Record; + return ( + typeof t.id === 'number' && + typeof t.project_id === 'number' && + typeof t.title === 'string' && + typeof t.status === 'string' + ); +} + +/** + * Valid task status values for strict validation. + * Must match TaskStatus type above. + */ +const VALID_TASK_STATUSES: readonly TaskStatus[] = [ + 'pending', + 'assigned', + 'in_progress', + 'blocked', + 'completed', + 'failed', +]; + +/** + * Parses a comma-separated string of task IDs into a number array. + * Filters out invalid values (empty strings, NaN). + */ +function parseDependsOn(dependsOn: string | undefined): number[] { + if (!dependsOn || dependsOn.trim() === '') { + return []; + } + return dependsOn + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== '') + .map((s) => parseInt(s, 10)) + .filter((n) => !isNaN(n)); +} + +/** + * Transforms an API task response to internal Task type. + * Uses strict status validation - invalid statuses default to 'pending'. + */ +export function transformAPITask(apiTask: APITaskResponse): Task { + // Strict status validation: only accept known values, default to 'pending' + const status: TaskStatus = VALID_TASK_STATUSES.includes(apiTask.status as TaskStatus) + ? (apiTask.status as TaskStatus) + : 'pending'; + + // Parse depends_on string into blocked_by number array + const blocked_by = parseDependsOn(apiTask.depends_on); + + return { + id: apiTask.id, + project_id: apiTask.project_id, + title: apiTask.title, + status, + agent_id: apiTask.assigned_to, + blocked_by: blocked_by.length > 0 ? blocked_by : undefined, + progress: apiTask.progress, + timestamp: apiTask.timestamp || Date.now(), + }; +} + /** * Single entry in the activity feed */ @@ -165,6 +260,15 @@ export interface AgentsLoadedAction { payload: Agent[]; } +/** + * Load initial tasks from API + * Enables returning users to see tasks without WebSocket events + */ +export interface TasksLoadedAction { + type: 'TASKS_LOADED'; + payload: Task[]; +} + /** * New agent created by backend */ @@ -288,6 +392,7 @@ export interface FullResyncAction { */ export type AgentAction = | AgentsLoadedAction + | TasksLoadedAction | AgentCreatedAction | AgentUpdatedAction | AgentRetiredAction