import { test, expect } from '@playwright/test'; /** * E2E tests for the onboarding and analysis user flows. * * These tests cover: * - Flow 1: OnboardingGuide shown when no server is running * - Flow 2: Analyze form when server has zero repos * - Flow 3: Auto-connect when server has repos * - Flow 4: Repo dropdown in exploring view * * Most tests mock the backend at the network level so they don't * require a live gitnexus server. */ const BACKEND_URL = 'http://localhost:4747'; async function enterExploringView(page: import('@playwright/test').Page) { await page.goto('/'); const landingCard = page.locator('[data-testid="landing-repo-card"]').first(); try { await landingCard.waitFor({ state: 'visible', timeout: 15_000 }); await landingCard.click(); } catch { // Landing screen may not appear (e.g. ?server auto-connect) } // Match the 45s budget used by waitForGraphLoaded() in // server-connect.spec.ts; under parallel CI workers, downloading the full // graph can occasionally exceed 30s. await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 45_000 }); } // ── Flow 1: Onboarding (no server running) ───────────────────────────────── test.describe('Flow 1: Onboarding — no server', () => { test('shows OnboardingGuide when backend is unreachable', async ({ page }, testInfo) => { // Block all requests to the backend so the probe fails await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused')); await page.goto('/'); // Wait for initial probe to complete and onboarding to appear await expect(page.getByText('Start your local server')).toBeVisible({ timeout: 10_000 }); await page.screenshot({ path: testInfo.outputPath('onboarding-visible.png') }); }); test('shows step-by-step instructions', async ({ page }) => { await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused')); await page.goto('/'); // Step 1 is active (done once polling starts) await expect(page.getByText('Copy the command')).toBeAttached({ timeout: 10_000 }); // Step 2 title changes to "Waiting for server to start" once polling begins await expect(page.getByText('Waiting for server to start')).toBeAttached({ timeout: 10_000 }); // Step 3 is always rendered await expect(page.getByText('Auto-connects and opens the graph')).toBeAttached({ timeout: 5_000, }); }); test('shows terminal window with command', async ({ page }) => { await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused')); await page.goto('/'); // Should show either dev or prod command in a terminal block const terminal = page.locator('code'); await expect(terminal.first()).toBeVisible({ timeout: 10_000 }); // The $ prompt should be present await expect(page.getByText('$')).toBeVisible(); }); test('shows polling indicator', async ({ page }) => { await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused')); await page.goto('/'); // Polling starts after initial probe fails await expect(page.getByText('Listening for server')).toBeVisible({ timeout: 10_000 }); }); test('shows Node.js version requirement', async ({ page }) => { await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused')); await page.goto('/'); await expect(page.getByText(/Node\.js.*\d+/)).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('Port 4747')).toBeVisible(); }); test('copy button has accessible label', async ({ page }) => { await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused')); await page.goto('/'); await expect(page.getByText('Copy the command')).toBeVisible({ timeout: 10_000 }); const copyBtn = page.getByLabel('Copy to clipboard').first(); await expect(copyBtn).toBeVisible(); }); }); // ── Flow 2: Server detected → success → auto-connect ────────────────────── test.describe('Flow 2: Server detected — auto-connect', () => { test('shows success card when server becomes reachable', async ({ page }, testInfo) => { // Start with server unreachable let blockBackend = true; await page.route(`${BACKEND_URL}/**`, (route) => { if (blockBackend) return route.abort('connectionrefused'); // Let it through to the real handler below return route.fallback(); }); // Mock the backend responses for when we "start" the server await page.route(`${BACKEND_URL}/api/repos`, async (route) => { if (blockBackend) return route.abort('connectionrefused'); await route.fulfill({ json: [{ name: 'test-repo', path: '/tmp/test' }] }); }); await page.route(`${BACKEND_URL}/api/repo`, async (route) => { if (blockBackend) return route.abort('connectionrefused'); await route.fulfill({ json: { name: 'test-repo', path: '/tmp/test', repoPath: '/tmp/test' }, }); }); await page.route(`${BACKEND_URL}/api/graph**`, async (route) => { if (blockBackend) return route.abort('connectionrefused'); await route.fulfill({ json: { nodes: [], relationships: [] } }); }); await page.route(`${BACKEND_URL}/api/heartbeat`, async (route) => { if (blockBackend) return route.abort('connectionrefused'); // SSE response await route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, body: ':ok\n\n', }); }); await page.goto('/'); // Verify onboarding is shown first await expect(page.getByText('Start your local server')).toBeVisible({ timeout: 10_000 }); await page.screenshot({ path: testInfo.outputPath('before-server-start.png') }); // "Start" the server by unblocking requests blockBackend = false; // Wait for success card await expect(page.getByText('Server Connected')).toBeVisible({ timeout: 15_000 }); await page.screenshot({ path: testInfo.outputPath('success-card.png') }); }); test('transitions to analyze phase when server has zero repos', async ({ page }, testInfo) => { // Mock server with zero repos — repos endpoint returns empty array await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] })); await page.route(`${BACKEND_URL}/api/info`, (route) => route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }), ); await page.route(`${BACKEND_URL}/api/heartbeat`, (route) => route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream' }, body: ':ok\n\n', }), ); await page.goto('/'); // Should transition: onboarding → success → analyze (zero repos) // The analyze form tabs should be visible await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('analyze-empty-state.png') }); }); }); // ── Flow 3: Analyze form ─────────────────────────────────────────────────── test.describe('Flow 3: Analyze form', () => { test.beforeEach(async ({ page }) => { // Mock server with zero repos to show the analyze form await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] })); await page.route(`${BACKEND_URL}/api/info`, (route) => route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }), ); await page.route(`${BACKEND_URL}/api/heartbeat`, (route) => route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream' }, body: ':ok\n\n', }), ); }); test('GitHub URL tab validates input', async ({ page }, testInfo) => { await page.goto('/'); // Wait for analyze form (transition: onboarding → success → analyze) await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 }); // Type an invalid URL const input = page.locator('input[type="url"]'); await input.fill('not-a-url'); // Analyze button should be visible but disabled const analyzeBtn = page.getByRole('button', { name: /Analyze Repository/ }); await expect(analyzeBtn).toBeVisible(); // Type a valid GitHub URL await input.fill('https://github.com/anthropics/courses'); await page.screenshot({ path: testInfo.outputPath('valid-github-url.png') }); }); test('Local Folder tab shows browse button', async ({ page }, testInfo) => { await page.goto('/'); await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 }); // Switch to Local Folder tab await page.getByRole('tab', { name: 'Local Folder' }).click(); // Upload-a-folder button should be visible (browser folder upload) await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('local-folder-tab.png') }); }); test('switching tabs clears input', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 }); // Type in GitHub URL const urlInput = page.locator('input[type="url"]'); await urlInput.fill('https://github.com/test/repo'); // Switch to Local Folder await page.getByRole('tab', { name: 'Local Folder' }).click(); // Switch back to GitHub URL — input should be empty await page.getByRole('tab', { name: 'GitHub URL' }).click(); const newUrlInput = page.locator('input[type="url"]'); await expect(newUrlInput).toHaveValue(''); }); }); // ── Flow 4: Repo dropdown (requires running server) ──────────────────────── test.describe('Flow 4: Repo dropdown in exploring view', () => { const SKIP_MSG = 'Requires running gitnexus server with indexed repos'; // enterExploringView() can take up to ~45s under parallel CI workers; combined // with the dropdown interactions this can exceed the default 60s test budget. test.slow(); test.beforeAll(async () => { if (process.env.E2E) return; try { const res = await fetch(`${BACKEND_URL}/api/repos`); if (!res.ok) { test.skip(true, SKIP_MSG); return; } const repos = await res.json(); if (!repos.length) { test.skip(true, 'Server has no indexed repos'); return; } } catch { test.skip(true, SKIP_MSG); } }); test('project badge opens repo dropdown', async ({ page }, testInfo) => { await enterExploringView(page); await page.screenshot({ path: testInfo.outputPath('exploring-loaded.png') }); // Click the project badge (has a chevron) const badge = page .locator('header button') .filter({ has: page.locator('svg') }) .first(); await badge.click(); // Repo dropdown should be visible await expect(page.getByText('Repositories')).toBeVisible({ timeout: 5_000 }); await expect(page.getByText('Analyze a new repository')).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('repo-dropdown-open.png') }); }); test('analyze option opens inline form', async ({ page }, testInfo) => { await enterExploringView(page); // Open repo dropdown const badge = page .locator('header button') .filter({ has: page.locator('svg') }) .first(); await badge.click(); // Click "Analyze a new repository..." await page.getByText('Analyze a new repository').click(); // Should show the analyze form inline await expect(page.getByText('GitHub URL')).toBeVisible({ timeout: 5_000 }); await expect(page.getByText('Local Folder')).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('inline-analyze-form.png') }); }); });