diff --git a/gitnexus-web/e2e/repo-switching.spec.ts b/gitnexus-web/e2e/repo-switching.spec.ts index 22eefd4a9..4cf6bf8c8 100644 --- a/gitnexus-web/e2e/repo-switching.spec.ts +++ b/gitnexus-web/e2e/repo-switching.spec.ts @@ -1,162 +1,133 @@ import { test, expect } from '@playwright/test'; /** - * E2E tests for the repo-switching and false-404 fixes introduced in - * fix: resolve false 404s and stale repo context during multi-repo switching on Windows + * E2E tests for the repo-switching and false-404 fixes. * - * All tests use Playwright route interception — no live gitnexus server required. - * - * Covers: - * 1. Hold-queue: /api/repo returns 503 → UI shows descriptive timeout message - * 2. ?project= URL persistence: handleServerConnect sets ?project= after connect - * 3. ?project= auto-connect: navigating to /?project= auto-connects - * 4. Windows path normalization: repoPath with backslashes → correct project name - * 5. Repo-switch preserves URL ?project= on every switch + * Most tests use the live backend (same pattern as multi-repo-scoping.spec.ts). + * The 503 hold-queue test uses route interception to simulate a slow analysis. */ -const BACKEND_URL = 'http://localhost:4747'; +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747'; +const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173'; -/** Minimal mock set for a server with one indexed repo named `repoName`. */ -async function mockServerWithRepo( - page: import('@playwright/test').Page, - repoName: string, - repoPath = `/tmp/${repoName}`, -) { - await page.route(`${BACKEND_URL}/api/heartbeat`, (route) => - route.fulfill({ - status: 200, - headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, - body: ':ok\n\n', - }), - ); - 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/repos`, (route) => - route.fulfill({ json: [{ name: repoName, path: repoPath }] }), - ); - await page.route(`${BACKEND_URL}/api/repo**`, (route) => - route.fulfill({ json: { name: repoName, path: repoPath, repoPath } }), - ); - await page.route(`${BACKEND_URL}/api/graph**`, (route) => - route.fulfill({ json: { nodes: [], relationships: [] } }), - ); - await page.route(`${BACKEND_URL}/api/embeddings**`, (route) => route.fulfill({ status: 200 })); -} +let firstRepoName: string; + +test.beforeAll(async () => { + if (process.env.E2E) { + try { + const res = await fetch(`${BACKEND_URL}/api/repos`); + const repos = await res.json(); + firstRepoName = repos[0]?.name ?? ''; + } catch { + firstRepoName = ''; + } + return; + } + try { + const [backendRes, frontendRes] = await Promise.allSettled([ + fetch(`${BACKEND_URL}/api/repos`), + fetch(FRONTEND_URL), + ]); + if ( + backendRes.status === 'rejected' || + (backendRes.status === 'fulfilled' && !backendRes.value.ok) + ) { + test.skip(true, 'gitnexus serve not available'); + return; + } + if ( + frontendRes.status === 'rejected' || + (frontendRes.status === 'fulfilled' && !frontendRes.value.ok) + ) { + test.skip(true, 'Vite dev server not available'); + return; + } + if (backendRes.status === 'fulfilled') { + const repos = await backendRes.value.json(); + if (!repos.length) { + test.skip(true, 'No indexed repos'); + return; + } + firstRepoName = repos[0].name; + } + } catch { + test.skip(true, 'servers not available'); + } +}); // ── 1. Hold-queue: 503 → descriptive user message ──────────────────────────── test.describe('Hold-queue timeout error', () => { test('shows descriptive message when /api/repo returns 503', async ({ page }, testInfo) => { - const repoName = 'flash-pkg'; - - // Server is up — heartbeat, info, repos all respond normally - await page.route(`${BACKEND_URL}/api/heartbeat`, (route) => - route.fulfill({ - status: 200, - headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, - body: ':ok\n\n', - }), - ); - 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/repos`, (route) => - route.fulfill({ json: [{ name: repoName, path: `/tmp/${repoName}` }] }), - ); - await page.route(`${BACKEND_URL}/api/graph**`, (route) => - route.fulfill({ json: { nodes: [], relationships: [] } }), - ); - - // /api/repo returns 503 (hold-queue timed out — analysis taking too long) - await page.route(`${BACKEND_URL}/api/repo**`, (route) => + // Intercept only /api/repo (singular) — not /api/repos — to return a 503 + // regex: /api/repo followed by end, ?, or # — NOT /api/repos + await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) => route.fulfill({ status: 503, - json: { - error: `Repository analysis for "${repoName}" is taking longer than expected. Please try again in a moment.`, - }, + contentType: 'application/json', + body: JSON.stringify({ + error: `Repository analysis for "${firstRepoName}" is taking longer than expected. Please try again in a moment.`, + }), }), ); await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); - // The UI should show the 503 error message, not a generic "404" or blank screen - await expect(page.getByText(/taking longer than expected/i)).toBeVisible({ timeout: 15_000 }); + // UI should show the 503 error message + await expect(page.getByText(/taking longer than expected/i)).toBeVisible({ + timeout: 20_000, + }); await page.screenshot({ path: testInfo.outputPath('hold-queue-503.png') }); }); }); -// ── 2. ?project= URL set after connect ─────────────────────────────────────── +// ── 2. ?project= URL persistence ───────────────────────────────────────────── test.describe('?project= URL persistence', () => { - test('?project= is added to URL after connecting via landing card', async ({ page }) => { - const repoName = 'flash-pkg'; - await mockServerWithRepo(page, repoName); - - await page.goto('/'); - - // Click the landing card to connect - const landingCard = page.locator('[data-testid="landing-repo-card"]').first(); - try { - await landingCard.waitFor({ state: 'visible', timeout: 15_000 }); - await landingCard.click(); - } catch { - // may auto-connect - } + test('?project= is set in URL after connecting via ?server=', async ({ page }) => { + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); - // URL should contain ?project= with the connected repo name const url = new URL(page.url()); const project = url.searchParams.get('project'); expect(project).toBeTruthy(); - expect(project).toBe(repoName); + // first repo returned by the live backend + if (firstRepoName) expect(project).toBe(firstRepoName); }); - test('?project= persists after F5 reload', async ({ page }) => { - const repoName = 'flash-pkg'; - await mockServerWithRepo(page, repoName); - - 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 { - // may auto-connect - } - + test('?project= is still present after F5 reload', async ({ page }) => { + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); - // Reload — ?project= should remain in URL and display correctly + // After connect, URL has ?server=&project= — F5 re-uses both params await page.reload(); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); - // After F5, the app uses ?project= to reconnect — should reach exploring view again const url = new URL(page.url()); - const project = url.searchParams.get('project'); - expect(project).toBe(repoName); + expect(url.searchParams.get('project')).toBeTruthy(); }); }); -// ── 3. ?project= auto-connect ───────────────────────────────────────────────── +// ── 3. ?project= + ?server= combined auto-connect ──────────────────────────── test.describe('?project= auto-connect', () => { - test('navigating to /?project= auto-connects without onboarding', async ({ + test('navigating with ?server=&project= connects to the correct repo', async ({ page, }, testInfo) => { - const repoName = 'flash-pkg'; - await mockServerWithRepo(page, repoName); + if (!firstRepoName) test.skip(true, 'no repo name available'); - // Navigate directly with ?project= (the bookmarked URL) - await page.goto(`/?project=${encodeURIComponent(repoName)}`); + await page.goto( + `/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(firstRepoName)}`, + ); - // Should skip onboarding and reach exploring view await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); - // Onboarding should NOT be visible - await expect(page.getByText('Start your local server')).not.toBeVisible(); + // ?project= in URL should match what we passed in + const url = new URL(page.url()); + expect(url.searchParams.get('project')).toBe(firstRepoName); + await page.screenshot({ path: testInfo.outputPath('project-param-connect.png') }); }); }); @@ -164,54 +135,34 @@ test.describe('?project= auto-connect', () => { // ── 4. Windows path normalization ───────────────────────────────────────────── test.describe('Windows path normalization', () => { - test('project name uses basename when repoPath contains Windows backslashes', async ({ + test('project name uses basename when /api/repo returns a Windows-style repoPath', async ({ page, }) => { - const repoName = 'my-repo'; + const repoName = firstRepoName || 'test-repo'; const windowsPath = `C:\\Users\\LENOVO\\.gitnexus\\repos\\${repoName}`; - // Mock server returns a Windows-style path in repoPath - await page.route(`${BACKEND_URL}/api/heartbeat`, (route) => + // Mock /api/repo to return a Windows backslash path while keeping name correct + await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) => route.fulfill({ - status: 200, - headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, - body: ':ok\n\n', + contentType: 'application/json', + body: JSON.stringify({ + // intentionally omit `name` to force path-based extraction + path: windowsPath, + repoPath: windowsPath, + }), }), ); - 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/repos`, (route) => - route.fulfill({ json: [{ name: repoName, path: windowsPath }] }), - ); - // Intentionally omit `name` in /api/repo to force path-based extraction - await page.route(`${BACKEND_URL}/api/repo**`, (route) => - route.fulfill({ - json: { name: repoName, path: windowsPath, repoPath: windowsPath }, - }), - ); - await page.route(`${BACKEND_URL}/api/graph**`, (route) => - route.fulfill({ json: { nodes: [], relationships: [] } }), - ); - await page.route(`${BACKEND_URL}/api/embeddings**`, (route) => route.fulfill({ status: 200 })); - 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 { - // may auto-connect - } + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); - // URL ?project= should contain the short name, NOT the full Windows path + // URL ?project= must be the short basename, NOT the full Windows path const url = new URL(page.url()); const project = url.searchParams.get('project'); - expect(project).toBe(repoName); + expect(project).toBeTruthy(); expect(project).not.toContain('\\'); expect(project).not.toContain('LENOVO'); + expect(project).toBe(repoName); }); });