Spaces:
Sleeping
Sleeping
| /** | |
| * ============================================================================ | |
| * GraphRAG β End-to-End UI Suite (Playwright) | |
| * ============================================================================ | |
| * | |
| * WHAT THIS SUITE COVERS (full critical-journey flow against the live stack): | |
| * Frontend : http://127.0.0.1:3000 (Next.js App Router) | |
| * Backend : http://127.0.0.1:8000/api (Neo4j + JWT auth) | |
| * | |
| * HOW TO RUN | |
| * ---------- | |
| * 1. Start the backend (FastAPI on :8000) and Neo4j. | |
| * 2. Start the frontend: cd frontend && npm run dev (serves on :3000) | |
| * 3. Install the runner: npm install (adds @playwright/test) | |
| * npx playwright install chromium | |
| * 4. Run the suite: npm run test:e2e | |
| * Run one file: npx playwright test e2e/graphrag.spec.ts | |
| * Repeat to prove determinism (no sleeps β should be green 10x): | |
| * npx playwright test --repeat-each=10 | |
| * Headed / debug: npx playwright test --project=chromium --headed | |
| * npx playwright test --debug | |
| * | |
| * ARTIFACTS ON FAILURE: trace (on-first-retry), screenshot, and video are | |
| * configured in ../playwright.config.ts, so every red run is debuggable from | |
| * CI without a local repro. | |
| * | |
| * ENCODED KNOWN GAPS (visible as soft-assertion failures / comments): | |
| * GAP #1 Register form: backend RegisterSerializer requires `confirm_password`, | |
| * but app/register POSTs only {username,email,password}. UI registration | |
| * therefore 400s and never redirects to '/'. See test "registration". | |
| * GAP #2 GET /graph/ returns 0 edges. The visualization therefore renders | |
| * nodes but no relationships. See test "explore". | |
| * (Also noted inline: several pages fall back to mock data on API error, which | |
| * can mask a failing backend β captured with soft assertions on response status.) | |
| * ============================================================================ | |
| */ | |
| import { test, expect, type Page } from '@playwright/test'; | |
| import { apiUser, seedAuth, API_BASE_URL, type AuthUser } from './fixtures'; | |
| const TEST_DOC = '/tmp/opencode/test_doc.md'; | |
| /** Number of graph nodes we expect the backend to expose (GAP #2 context). */ | |
| async function graphNodeEdgeCounts(page: Page): Promise<{ nodes: number; edges: number }> { | |
| const resp = await page.waitForResponse( | |
| (r) => r.url().match(/\/graph\/?(\?.*)?$/) !== null && r.request().method() === 'GET', | |
| ); | |
| const body = (await resp.json()) as { nodes?: any[]; links?: any[]; edges?: any[] }; | |
| return { | |
| nodes: (body.nodes || []).length, | |
| edges: (body.links || body.edges || []).length, | |
| }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 1) REGISTRATION (public route, no seeded auth) | |
| // --------------------------------------------------------------------------- | |
| test.describe('Auth Β· Registration (UI)', () => { | |
| test('fills register form and expects redirect to dashboard', async ({ page }) => { | |
| // KNOWN GAP #1 (encoded as an expected failure): | |
| // The register page DOES render a "Confirm Password" input, but its submit | |
| // handler posts only {username, email, password} to /auth/register/. | |
| // The backend's RegisterSerializer marks `confirm_password` as required=True, | |
| // so the request is rejected with HTTP 400 and the client-side redirect to | |
| // '/' never fires. | |
| // `test.fail()` tells Playwright this test is *expected* to fail while the | |
| // gap is open (keeps CI green); when the form is fixed and the redirect | |
| // succeeds, this flips to "passed unexpectedly" β remove test.fail(). | |
| test.fail(); | |
| await page.goto('/register'); | |
| const username = `uiform_${Date.now()}`; | |
| const email = `${username}@test.local`; | |
| await page.getByPlaceholder('johnsmith').fill(username); | |
| await page.getByPlaceholder('you@example.com').fill(email); | |
| // Two password inputs on the register page: [0]=Password, [1]=Confirm Password. | |
| const passwords = page.locator('input[type="password"]'); | |
| await passwords.nth(0).fill('E2ePassw0rd!9'); | |
| await passwords.nth(1).fill('E2ePassw0rd!9'); | |
| // Capture the registration API call to document the backend contract failure. | |
| const regResp = page.waitForResponse( | |
| (r) => r.url().includes('/auth/register/') && r.request().method() === 'POST', | |
| ); | |
| await page.getByRole('button', { name: 'Create Account' }).click(); | |
| const resp = await regResp; | |
| // Sanity log so a trace shows the real 400 vs. a different failure mode. | |
| test.info().annotations.push({ type: 'GAP#1', description: `register status=${resp.status()}` }); | |
| // Intended (post-fix) behavior: | |
| await expect(page, 'expected redirect to dashboard (/) after successful register').toHaveURL(/\/$/); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // 2) DASHBOARD QUERY (authenticated) | |
| // --------------------------------------------------------------------------- | |
| test('dashboard: query returns an answer and the graph canvas renders', async ({ page, apiUser }) => { | |
| await seedAuth(page, apiUser as AuthUser); | |
| await page.goto('/'); | |
| const queryInput = page.getByPlaceholder('Ask anything about your documents...'); | |
| await expect(queryInput).toBeVisible(); | |
| await queryInput.fill('What entities and relationships exist in the knowledge graph?'); | |
| const answerResp = page.waitForResponse( | |
| (r) => r.url().includes('/query/') && r.request().method() === 'POST', | |
| ); | |
| await page.getByRole('button', { name: /submit/i }).click(); | |
| const qr = await answerResp; | |
| // AnswerCard only renders `.prose-invert` when `answer` is non-empty. | |
| const answer = page.locator('.prose-invert').first(); | |
| await expect(answer, 'an answer should render in the AnswerCard').toBeVisible(); | |
| await expect(answer, 'answer should contain content').not.toBeEmpty(); | |
| // KNOWN FRAGILITY: MainQueryView falls back to mockAnswer on any API error, | |
| // so a visible answer can mask a broken backend. Assert the real response. | |
| expect | |
| .soft(qr.status(), 'GAP: /query/ may be masked by mock fallback on error β expect 200') | |
| .toBe(200); | |
| // The graph canvas mounts on the dashboard when graph data is present. | |
| await expect(page.locator('.graph-canvas').first(), 'graph canvas should mount').toBeVisible(); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // 3) DOCUMENTS UPLOAD (authenticated) | |
| // --------------------------------------------------------------------------- | |
| test('documents: uploaded markdown appears in the list and reaches COMPLETED', async ({ page, apiUser }) => { | |
| await seedAuth(page, apiUser as AuthUser); | |
| await page.goto('/documents'); | |
| const fileInput = page.locator('input[type="file"]'); | |
| await expect(fileInput, 'upload input should be present').toBeAttached(); | |
| await fileInput.setInputFiles(TEST_DOC); | |
| // The row for our file is upserted client-side on the upload response, so it | |
| // appears regardless of backend β but it MUST appear (list visibility). | |
| const fileRow = page.getByText('test_doc.md', { exact: true }).first(); | |
| await expect(fileRow, 'uploaded file should appear in the document list').toBeVisible({ | |
| timeout: 15000, | |
| }); | |
| // KNOWN FRAGILITY: DocumentUpload mocks a PROCESSING status on upload error, | |
| // so a failing backend will NOT surface as a UI error. We assert the genuine | |
| // target state: the row progresses to COMPLETED via the polling hook. | |
| await expect( | |
| page.getByText('COMPLETED', { exact: true }).first(), | |
| 'document should reach COMPLETED status', | |
| ).toBeVisible({ timeout: 60000 }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // 4) EXPLORE (authenticated) β graph canvas + stats sidebar | |
| // --------------------------------------------------------------------------- | |
| test('explore: graph canvas and stats sidebar render', async ({ page, apiUser }) => { | |
| await seedAuth(page, apiUser as AuthUser); | |
| await page.goto('/explore'); | |
| // Canvas + the stats sidebar ("Nodes" / "Edges" stat cards from GraphStats). | |
| await expect(page.locator('.graph-canvas').first(), 'graph canvas should render').toBeVisible(); | |
| await expect(page.getByText('Nodes', { exact: true }), 'stats: Nodes card').toBeVisible(); | |
| await expect(page.getByText('Edges', { exact: true }), 'stats: Edges card').toBeVisible(); | |
| // Capture the real backend graph payload to assert observable structure. | |
| const { nodes, edges } = await graphNodeEdgeCounts(page); | |
| // Real assertion: the graph should expose at least one node. | |
| expect(nodes, 'GET /graph/ should return at least one node').toBeGreaterThan(0); | |
| // KNOWN GAP #2: backend GET /graph/ currently returns 0 edges. We assert it | |
| // explicitly (soft) so the missing-relationships defect is always visible. | |
| expect | |
| .soft(edges, 'GAP #2: backend GET /graph/ returns 0 edges (visualization gap)') | |
| .toBe(0); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // 5) COMPARE (authenticated) β 3 columns + verdict | |
| // --------------------------------------------------------------------------- | |
| test('compare: running a comparison shows three strategy columns and a verdict', async ({ page, apiUser }) => { | |
| await seedAuth(page, apiUser as AuthUser); | |
| await page.goto('/compare'); | |
| const cmpResp = page.waitForResponse( | |
| (r) => r.url().includes('/query/compare/') && r.request().method() === 'POST', | |
| ); | |
| await page.getByPlaceholder('Ask anything about your documents...').fill( | |
| 'Compare graph, vector, and hybrid retrieval for this corpus', | |
| ); | |
| await page.getByRole('button', { name: /submit/i }).click(); | |
| await cmpResp; | |
| // ComparisonView renders three columns (Graph Only / Vector Only / Hybrid) | |
| // and a Verdict card. (Falls back to mockComparison on API error, so columns | |
| // appear even if the backend compare endpoint is down β see GAP note.) | |
| await expect(page.getByText('Graph Only'), 'column: Graph Only').toBeVisible(); | |
| await expect(page.getByText('Vector Only'), 'column: Vector Only').toBeVisible(); | |
| await expect(page.getByText('Hybrid'), 'column: Hybrid').toBeVisible(); | |
| await expect(page.getByText('Verdict', { exact: true }), 'verdict section').toBeVisible(); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // 6) COMMUNITIES (authenticated) β community cards | |
| // --------------------------------------------------------------------------- | |
| test('communities: community cards render', async ({ page, apiUser }) => { | |
| await seedAuth(page, apiUser as AuthUser); | |
| const commResp = page.waitForResponse((r) => r.url().includes('/graph/communities/')); | |
| await page.goto('/communities'); | |
| await commResp; | |
| // CommunityView defaults to the "Cards" tab and seeds mock communities on | |
| // first render, then tries to hydrate from /graph/communities/. Each card | |
| // exposes an "Expand Graph" action β a reliable card-presence signal. | |
| await expect(page.getByRole('tab', { name: 'Cards' }), 'Cards tab').toBeVisible(); | |
| await expect(page.getByRole('tab', { name: 'Graph' }), 'Graph tab').toBeVisible(); | |
| const cards = page.getByText('Expand Graph'); | |
| await expect(cards, 'at least one community card should render').toHaveCountGreaterThan(0); | |
| }); | |