Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions tests/e2e/seed-test-data.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,68 @@ def seed_test_data(db_path: str, project_id: int):
print("✅ Seeded test user (email: test@example.com, password: Testpassword123)")
print(" Note: E2E tests will use real login flow via FastAPI Users JWT")

# ========================================
# 0.5. Ensure Project 1 has proper user ownership
# ========================================
# The global-setup creates Project 1 via API, but we need to ensure:
# 1. The project exists with user_id=1 (test user)
# 2. The workspace path is set correctly
# 3. The status and phase are initialized
# This is critical for authorization checks in checkpoint API
print("📦 Ensuring project 1 ownership and configuration...")

workspace_path_p1 = os.path.join(E2E_TEST_ROOT, ".codeframe", "workspaces", str(project_id))
os.makedirs(workspace_path_p1, exist_ok=True)
print(f" 📁 Workspace: {workspace_path_p1}")

# Use UPDATE instead of INSERT OR REPLACE to preserve API-created fields
# But ensure user_id is set correctly for authorization
cursor.execute(
"""
UPDATE projects
SET user_id = 1, workspace_path = ?, status = COALESCE(status, 'discovery'), phase = COALESCE(phase, 'discovery')
WHERE id = ?
""",
(workspace_path_p1, project_id),
)

# If no row was updated (project doesn't exist), insert it
if cursor.rowcount == 0:
cursor.execute(
"""
INSERT INTO projects (id, name, description, user_id, workspace_path, status, phase, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
project_id,
"e2e-test-project",
"E2E Test Project (seeded)",
1, # test user
workspace_path_p1,
"discovery",
"discovery",
now_ts,
),
)
print(f"✅ Created project {project_id} with user_id=1")
else:
print(f"✅ Updated project {project_id} to ensure user_id=1")

# Verify the update was successful
cursor.execute(
"SELECT user_id, workspace_path FROM projects WHERE id = ?",
(project_id,),
)
row = cursor.fetchone()
if row:
db_user_id, db_workspace = row
if db_user_id != 1:
print(f"⚠️ WARNING: Project {project_id} has user_id={db_user_id}, expected 1")
else:
print(f" ✓ Verified: user_id=1, workspace={db_workspace}")
else:
print(f"❌ ERROR: Project {project_id} not found after insert/update!")

# ========================================
# 1. Seed Agents (5)
# ========================================
Expand Down Expand Up @@ -1126,6 +1188,34 @@ def seed_test_data(db_path: str, project_id: int):
count = cursor.fetchone()[0]
print(f"✅ Seeded {count}/3 checkpoints with files")

# Verify checkpoint records were created correctly
print("🔍 Verifying checkpoint records...")
cursor.execute(
"""
SELECT id, name, project_id, database_backup_path, context_snapshot_path
FROM checkpoints
WHERE project_id = ?
ORDER BY id
""",
(project_id,),
)
checkpoint_rows = cursor.fetchall()
for cp_id, cp_name, cp_project_id, db_path, ctx_path in checkpoint_rows:
# Verify file paths exist
full_db_path = os.path.join(E2E_TEST_ROOT, db_path)
full_ctx_path = os.path.join(E2E_TEST_ROOT, ctx_path)
db_exists = os.path.exists(full_db_path)
ctx_exists = os.path.exists(full_ctx_path)
status = "✓" if (db_exists and ctx_exists) else "✗"
print(f" {status} Checkpoint {cp_id}: '{cp_name}' (project={cp_project_id})")
if not db_exists:
print(f" ⚠️ Missing DB: {db_path}")
if not ctx_exists:
print(f" ⚠️ Missing context: {ctx_path}")

if count < 3:
print(f"⚠️ WARNING: Expected 3 checkpoints, only {count} created")

# ========================================
# 7. Seed Discovery State for E2E Tests
# ========================================
Expand Down
153 changes: 142 additions & 11 deletions tests/e2e/test_checkpoint_ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,109 @@
* - Checkpoint metadata is visible
*/

import { test, expect } from '@playwright/test';
import { loginUser, createTestProject } from './test-utils';
import { test, expect, Response } from '@playwright/test';
import { loginUser, createTestProject, setupErrorMonitoring, getAuthToken } from './test-utils';
import { BACKEND_URL } from './e2e-config';

const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3001';
const PROJECT_ID = process.env.E2E_TEST_PROJECT_ID || '1';

/**
* Timeout constants for checkpoint tests.
* Centralized for easier maintenance and clearer test expectations.
*/
const TIMEOUTS = {
/** Timeout for API responses */
API_RESPONSE: 10000,
/** Timeout for checkpoint API which may be slower */
CHECKPOINT_LOAD: 15000,
/** Timeout for DOM updates after API calls */
DOM_UPDATE: 5000,
/** Brief delay for UI rendering after API response */
UI_RENDER: 500,
} as const;

/**
* API Health Check - Runs before the main test suite to verify
* that the checkpoint API is accessible and working.
*/
test.describe('Checkpoint API Health Check', () => {
test('checkpoint API endpoint is accessible', async ({ page }) => {
// Login to get auth token
await loginUser(page);

// Get auth token from localStorage
const authToken = await getAuthToken(page);
expect(authToken).toBeTruthy();
console.log('[Health Check] Auth token obtained');

// Make direct API call to checkpoint endpoint
const response = await page.request.get(
`${BACKEND_URL}/api/projects/${PROJECT_ID}/checkpoints`,
{
headers: {
Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
}
);

const status = response.status();
console.log(`[Health Check] Checkpoint API status: ${status}`);

// Log response body for debugging
const body = await response.text();
if (status !== 200) {
console.log(`[Health Check] Error response: ${body}`);
} else {
try {
const data = JSON.parse(body);
console.log(`[Health Check] Found ${data.checkpoints?.length || 0} checkpoints`);
} catch {
console.log(`[Health Check] Response: ${body.substring(0, 200)}`);
}
}

// Verify response
expect(status).toBe(200);

const data = await response.json();
expect(data).toHaveProperty('checkpoints');
expect(Array.isArray(data.checkpoints)).toBe(true);
});
});

test.describe('Checkpoint UI Workflow', () => {
// Store checkpoint response for tests that need it
let checkpointApiResponsePromise: Promise<Response> | null = null;

test.beforeEach(async ({ page }) => {
// Set up error monitoring
setupErrorMonitoring(page);

// Monitor all API responses for debugging
page.on('response', async (response) => {
if (response.url().includes('/checkpoints')) {
const status = response.status();
console.log(`[Checkpoint API] ${response.url()} - Status: ${status}`);
if (status !== 200) {
try {
const body = await response.text();
console.log(`[Checkpoint API] Error response: ${body}`);
} catch {
console.log('[Checkpoint API] Could not read error response body');
}
}
}
});

// Monitor failed requests
page.on('requestfailed', (request) => {
if (request.url().includes('/checkpoints')) {
console.log(`[Checkpoint API] Request failed: ${request.url()} - ${request.failure()?.errorText}`);
}
});

// Login using real authentication flow
await loginUser(page);

Expand All @@ -27,19 +122,40 @@ test.describe('Checkpoint UI Workflow', () => {
// Note: Must use /api/projects/ to avoid matching the HTML page response at /projects/
const projectResponse = await page.waitForResponse(response =>
response.url().includes(`/api/projects/${PROJECT_ID}`) && response.status() === 200,
{ timeout: 10000 }
{ timeout: TIMEOUTS.API_RESPONSE }
);
expect(projectResponse.ok()).toBe(true);

// Navigate to checkpoint section - tab MUST be visible
const checkpointTab = page.locator('[data-testid="checkpoint-tab"]');
await checkpointTab.waitFor({ state: 'visible', timeout: 10000 });
await checkpointTab.waitFor({ state: 'visible', timeout: TIMEOUTS.API_RESPONSE });
await expect(checkpointTab).toBeVisible();

// CRITICAL: Set up response listener BEFORE clicking tab to avoid race condition
// The checkpoint API call fires when the tab is clicked, so we need to listen first
checkpointApiResponsePromise = page.waitForResponse(
response => response.url().includes('/checkpoints') && !response.url().includes('/diff'),
{ timeout: TIMEOUTS.CHECKPOINT_LOAD }
);

await checkpointTab.click();

// Wait for checkpoint panel to become visible after tab switch
const checkpointPanel = page.locator('[data-testid="checkpoint-panel"]');
await checkpointPanel.waitFor({ state: 'visible', timeout: 5000 });
await checkpointPanel.waitFor({ state: 'visible', timeout: TIMEOUTS.DOM_UPDATE });

// Wait for the checkpoint API call to complete (set up before click)
try {
const checkpointResponse = await checkpointApiResponsePromise;
const status = checkpointResponse.status();
console.log(`[beforeEach] Checkpoint API completed with status: ${status}`);
if (status !== 200) {
const body = await checkpointResponse.text();
console.log(`[beforeEach] Checkpoint API error: ${body}`);
}
} catch (error) {
console.log(`[beforeEach] Checkpoint API response not captured: ${error}`);
}
});

test('should display checkpoint panel', async ({ page }) => {
Expand All @@ -66,29 +182,44 @@ test.describe('Checkpoint UI Workflow', () => {
await checkpointList.waitFor({ state: 'visible', timeout: 15000 });
await expect(checkpointList).toBeVisible();

// Wait for checkpoints API response - MUST succeed
const checkpointsResponse = await page.waitForResponse(response =>
response.url().includes('/checkpoints') && response.status() === 200,
{ timeout: 10000 }
);
expect(checkpointsResponse.ok()).toBe(true);
// API response was already captured in beforeEach
// Give UI time to render the data
await page.waitForTimeout(TIMEOUTS.UI_RENDER);

// Wait for DOM to update - either checkpoint items or empty state MUST be visible
const checkpointItems = page.locator('[data-testid^="checkpoint-item-"]');
const emptyState = page.locator('[data-testid="checkpoint-empty-state"]');
const loadingIndicator = page.locator('[data-testid="checkpoint-loading"]');
const errorIndicator = page.locator('[data-testid="checkpoint-error"]');

// Wait for loading to complete if it's visible
if (await loadingIndicator.isVisible()) {
console.log('[Test] Waiting for loading indicator to disappear...');
await loadingIndicator.waitFor({ state: 'hidden', timeout: 10000 });
}

// Check for error state first
if (await errorIndicator.isVisible()) {
const errorText = await errorIndicator.textContent();
console.log(`[Test] Error state detected: ${errorText}`);
throw new Error(`Checkpoint loading failed with error: ${errorText}`);
}

// One of these MUST appear - if neither does, that's a bug
await expect(checkpointItems.first().or(emptyState)).toBeVisible({ timeout: 5000 });

// Check if checkpoints are displayed (or empty state)
const count = await checkpointItems.count();
console.log(`[Test] Found ${count} checkpoint items`);

if (count === 0) {
// Empty state should be visible (already declared above)
await expect(emptyState).toBeVisible();
console.log('[Test] Empty state displayed (expected for new projects)');
} else {
// At least one checkpoint should be visible
expect(count).toBeGreaterThan(0);
console.log(`[Test] Displaying ${count} checkpoints`);

// Verify checkpoint metadata
const firstCheckpoint = checkpointItems.first();
Expand Down
48 changes: 44 additions & 4 deletions web-ui/src/api/checkpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,54 @@ import type {

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';

/**
* Error types for checkpoint API operations
*/
export class CheckpointApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly endpoint: string
) {
super(message);
this.name = 'CheckpointApiError';
}
}

/**
* List all checkpoints for a project
*
* @throws CheckpointApiError with status code for debugging
*/
export async function listCheckpoints(projectId: number): Promise<Checkpoint[]> {
const response = await authFetch<{ checkpoints: Checkpoint[] }>(
`${API_BASE_URL}/api/projects/${projectId}/checkpoints`
);
return response.checkpoints ?? [];
const endpoint = `${API_BASE_URL}/api/projects/${projectId}/checkpoints`;

try {
const response = await authFetch<{ checkpoints: Checkpoint[] }>(endpoint);
return response.checkpoints ?? [];
} catch (error) {
// Extract status code from error message if available
const statusMatch = (error as Error)?.message?.match(/Request failed: (\d+)/);
const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : 0;

// Log specific error types for debugging
if (statusCode === 401) {
console.warn('[Checkpoints API] Authentication required - token may be missing or expired');
} else if (statusCode === 403) {
console.warn('[Checkpoints API] Access denied - user may not have project access');
} else if (statusCode === 404) {
console.warn(`[Checkpoints API] Project ${projectId} not found`);
} else if (statusCode >= 500) {
console.error(`[Checkpoints API] Server error (${statusCode}) fetching checkpoints`);
}

// Re-throw with more context
throw new CheckpointApiError(
(error as Error)?.message || 'Failed to load checkpoints',
statusCode,
endpoint
);
}
}

/**
Expand Down
Loading
Loading