diff --git a/src/main/frontend/package-lock.json b/src/main/frontend/package-lock.json index 45e9c009..a4bc3e6e 100644 --- a/src/main/frontend/package-lock.json +++ b/src/main/frontend/package-lock.json @@ -5139,9 +5139,9 @@ "license": "MIT" }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.debounce": { diff --git a/src/main/frontend/package.json b/src/main/frontend/package.json index 5438d088..857c8aaf 100644 --- a/src/main/frontend/package.json +++ b/src/main/frontend/package.json @@ -33,7 +33,8 @@ "tailwindcss-animate": "^1.0.7" }, "overrides": { - "dompurify": "^3.3.3" + "dompurify": "^3.3.3", + "lodash": ">=4.17.24" }, "devDependencies": { "@axe-core/playwright": "^4.10.1", diff --git a/src/main/frontend/playwright-report/data/52d6e968bcc3e3f9fb8eaecdf7ea2f3d2ab41deb.png b/src/main/frontend/playwright-report/data/52d6e968bcc3e3f9fb8eaecdf7ea2f3d2ab41deb.png new file mode 100644 index 00000000..dba53727 Binary files /dev/null and b/src/main/frontend/playwright-report/data/52d6e968bcc3e3f9fb8eaecdf7ea2f3d2ab41deb.png differ diff --git a/src/main/frontend/playwright-report/data/813e22f62153becae158a32a0ef8aa8037da1508.webm b/src/main/frontend/playwright-report/data/813e22f62153becae158a32a0ef8aa8037da1508.webm new file mode 100644 index 00000000..65ff4263 Binary files /dev/null and b/src/main/frontend/playwright-report/data/813e22f62153becae158a32a0ef8aa8037da1508.webm differ diff --git a/src/main/frontend/playwright-report/data/8c72b585fa3d6ad6b85b44878d2427a335bdf9de.md b/src/main/frontend/playwright-report/data/8c72b585fa3d6ad6b85b44878d2427a335bdf9de.md new file mode 100644 index 00000000..e3e09aae --- /dev/null +++ b/src/main/frontend/playwright-report/data/8c72b585fa3d6ad6b85b44878d2427a335bdf9de.md @@ -0,0 +1,432 @@ +# Instructions + +- Following Playwright test failed. +- Explain why, be concise, respect Playwright best practices. +- Provide a snippet of code with the fix, if possible. + +# Test info + +- Name: search.spec.ts >> Global search >> keyboard navigation in search results (ArrowDown / Enter) +- Location: tests/e2e/search.spec.ts:106:3 + +# Error details + +``` +Error: expect(page).toHaveURL(expected) failed + +Expected pattern: /\/explorer|\/graph/ +Received string: "http://localhost:8080/" +Timeout: 5000ms + +Call log: + - Expect "toHaveURL" with timeout 5000ms + 8 × unexpected value "http://localhost:8080/" + +``` + +# Page snapshot + +```yaml +- generic [ref=e3]: + - complementary "Main navigation" [ref=e4]: + - generic [ref=e5]: + - generic [ref=e6]: + - img [ref=e7] + - generic [ref=e10]: IQ + - generic [ref=e11]: + - heading "Code IQ" [level=1] [ref=e12] + - paragraph [ref=e13]: Knowledge Graph + - button "Collapse sidebar" [ref=e14] [cursor=pointer]: + - img + - navigation [ref=e15]: + - link "Dashboard" [ref=e16] [cursor=pointer]: + - /url: / + - img [ref=e17] + - generic [ref=e22]: Dashboard + - link "Code Graph" [ref=e23] [cursor=pointer]: + - /url: /graph + - img [ref=e24] + - generic [ref=e29]: Code Graph + - link "Explorer" [ref=e30] [cursor=pointer]: + - /url: /explorer + - img [ref=e31] + - generic [ref=e35]: Explorer + - link "Console" [ref=e36] [cursor=pointer]: + - /url: /console + - img [ref=e37] + - generic [ref=e39]: Console + - link "API Docs" [ref=e40] [cursor=pointer]: + - /url: /api-docs + - img [ref=e41] + - generic [ref=e43]: API Docs + - generic [ref=e45]: + - generic [ref=e47]: + - paragraph [ref=e48]: Project Files + - generic [ref=e49]: 401 files + - generic [ref=e53]: + - img [ref=e54] + - textbox "Filter files" [ref=e57]: + - /placeholder: Filter files… + - tree "Project file tree" [ref=e58]: + - treeitem "Project 401" [expanded] [ref=e59] [cursor=pointer]: + - img [ref=e61] + - img [ref=e64] + - generic [ref=e66]: Project + - generic "401 graph nodes" [ref=e67]: "401" + - treeitem ".claude 3" [ref=e68] [cursor=pointer]: + - img [ref=e70] + - img [ref=e73] + - generic [ref=e75]: .claude + - generic "3 graph nodes" [ref=e76]: "3" + - treeitem ".github 31" [ref=e77] [cursor=pointer]: + - img [ref=e79] + - img [ref=e82] + - generic [ref=e84]: .github + - generic "31 graph nodes" [ref=e85]: "31" + - treeitem "pytest-of-dev 1" [ref=e86] [cursor=pointer]: + - img [ref=e88] + - img [ref=e91] + - generic [ref=e93]: pytest-of-dev + - generic "1 graph node" [ref=e94]: "1" + - treeitem "src 1.4k" [ref=e95] [cursor=pointer]: + - img [ref=e97] + - img [ref=e100] + - generic [ref=e102]: src + - generic "1439 graph nodes" [ref=e103]: 1.4k + - treeitem ". 1" [ref=e104] [cursor=pointer]: + - img [ref=e107] + - generic [ref=e110]: . + - generic "1 graph node" [ref=e111]: "1" + - treeitem "CLAUDE.md 43" [ref=e112] [cursor=pointer]: + - img [ref=e115] + - generic [ref=e118]: CLAUDE.md + - generic "43 graph nodes" [ref=e119]: "43" + - treeitem "README.md 53" [ref=e120] [cursor=pointer]: + - img [ref=e123] + - generic [ref=e126]: README.md + - generic "53 graph nodes" [ref=e127]: "53" + - treeitem "pom.xml 1" [ref=e128] [cursor=pointer]: + - img [ref=e131] + - generic [ref=e134]: pom.xml + - generic "1 graph node" [ref=e135]: "1" + - treeitem "sonar-project.properties 9" [ref=e136] [cursor=pointer]: + - img [ref=e139] + - generic [ref=e142]: sonar-project.properties + - generic "9 graph nodes" [ref=e143]: "9" + - generic [ref=e144]: + - banner [ref=e145]: + - generic [ref=e147]: + - generic [ref=e148]: + - img [ref=e149] + - searchbox "Search nodes, kinds, files..." [active] [ref=e152]: User + - button "Clear search" [ref=e153] [cursor=pointer]: + - img [ref=e154] + - listbox [ref=e157]: + - option "class UserService" [ref=e158] [cursor=pointer]: + - generic [ref=e159]: class + - generic [ref=e160]: UserService + - option "method findById" [ref=e161] [cursor=pointer]: + - generic [ref=e162]: method + - generic [ref=e163]: findById + - 'option "endpoint GET /users/{id}" [ref=e164] [cursor=pointer]': + - generic [ref=e165]: endpoint + - generic [ref=e166]: "GET /users/{id}" + - generic [ref=e167]: + - button "Toggle theme" [ref=e168] [cursor=pointer]: + - img + - generic [ref=e169]: Toggle theme + - button "User profile" [ref=e170] [cursor=pointer]: + - img + - main [ref=e174]: + - generic [ref=e175]: + - generic [ref=e176]: + - generic [ref=e177]: + - heading "Dashboard" [level=1] [ref=e178] + - paragraph [ref=e179]: Code knowledge graph overview + - button "Refresh stats" [ref=e180] [cursor=pointer]: + - img + - generic [ref=e181]: + - button "View Nodes in Explorer" [ref=e182] [cursor=pointer]: + - generic [ref=e185]: + - generic [ref=e186]: + - paragraph [ref=e187]: Nodes + - paragraph [ref=e188]: "0" + - paragraph [ref=e189]: Total graph nodes + - img [ref=e191] + - generic [ref=e197]: + - generic [ref=e198]: + - paragraph [ref=e199]: Edges + - paragraph [ref=e200]: "0" + - paragraph [ref=e201]: Relationships + - img [ref=e203] + - button "View Files in Explorer" [ref=e207] [cursor=pointer]: + - generic [ref=e210]: + - generic [ref=e211]: + - paragraph [ref=e212]: Files + - paragraph [ref=e213]: "0" + - paragraph [ref=e214]: Source files scanned + - img [ref=e216] + - generic [ref=e224]: + - generic [ref=e225]: + - paragraph [ref=e226]: Languages + - paragraph [ref=e227]: "0" + - paragraph [ref=e228]: Detected languages + - img [ref=e230] + - generic [ref=e235]: + - heading "Node Kinds" [level=3] [ref=e237]: + - img [ref=e238] + - text: Node Kinds + - list [ref=e241]: + - button "method 671 0" [ref=e242] [cursor=pointer]: + - generic [ref=e243]: + - generic [ref=e244]: method + - generic [ref=e245]: "671" + - progressbar [ref=e246] + - button "class 421 0" [ref=e247] [cursor=pointer]: + - generic [ref=e248]: + - generic [ref=e249]: class + - generic [ref=e250]: "421" + - progressbar [ref=e251] + - button "config_key 166 0" [ref=e252] [cursor=pointer]: + - generic [ref=e253]: + - generic [ref=e254]: config_key + - generic [ref=e255]: "166" + - progressbar [ref=e256] + - button "endpoint 74 0" [ref=e257] [cursor=pointer]: + - generic [ref=e258]: + - generic [ref=e259]: endpoint + - generic [ref=e260]: "74" + - progressbar [ref=e261] + - button "module 56 0" [ref=e262] [cursor=pointer]: + - generic [ref=e263]: + - generic [ref=e264]: module + - generic [ref=e265]: "56" + - progressbar [ref=e266] + - button "interface 54 0" [ref=e267] [cursor=pointer]: + - generic [ref=e268]: + - generic [ref=e269]: interface + - generic [ref=e270]: "54" + - progressbar [ref=e271] + - button "middleware 32 0" [ref=e272] [cursor=pointer]: + - generic [ref=e273]: + - generic [ref=e274]: middleware + - generic [ref=e275]: "32" + - progressbar [ref=e276] + - button "component 26 0" [ref=e277] [cursor=pointer]: + - generic [ref=e278]: + - generic [ref=e279]: component + - generic [ref=e280]: "26" + - progressbar [ref=e281] + - button "query 23 0" [ref=e282] [cursor=pointer]: + - generic [ref=e283]: + - generic [ref=e284]: query + - generic [ref=e285]: "23" + - progressbar [ref=e286] + - button "guard 19 0" [ref=e287] [cursor=pointer]: + - generic [ref=e288]: + - generic [ref=e289]: guard + - generic [ref=e290]: "19" + - progressbar [ref=e291] + - button "abstract_class 17 0" [ref=e292] [cursor=pointer]: + - generic [ref=e293]: + - generic [ref=e294]: abstract_class + - generic [ref=e295]: "17" + - progressbar [ref=e296] + - button "config_file 15 0" [ref=e297] [cursor=pointer]: + - generic [ref=e298]: + - generic [ref=e299]: config_file + - generic [ref=e300]: "15" + - progressbar [ref=e301] + - button "event 12 0" [ref=e302] [cursor=pointer]: + - generic [ref=e303]: + - generic [ref=e304]: event + - generic [ref=e305]: "12" + - progressbar [ref=e306] + - button "queue 10 0" [ref=e307] [cursor=pointer]: + - generic [ref=e308]: + - generic [ref=e309]: queue + - generic [ref=e310]: "10" + - progressbar [ref=e311] +``` + +# Test source + +```ts + 19 | await mockStats(page); + 20 | + 21 | // Mock search API + 22 | await page.route('**/api/search**', route => + 23 | route.fulfill({ + 24 | status: 200, + 25 | contentType: 'application/json', + 26 | body: JSON.stringify(MOCK_SEARCH_RESULTS), + 27 | }) + 28 | ); + 29 | }); + 30 | + 31 | test('search box is visible in header on all views', async ({ page }) => { + 32 | for (const route of Object.values(ROUTES)) { + 33 | await gotoRoute(page, route); + 34 | await expect(page.getByRole('searchbox')).toBeVisible(); + 35 | } + 36 | }); + 37 | + 38 | test('typing fewer than 2 characters does not trigger search', async ({ page }) => { + 39 | await gotoRoute(page, ROUTES.dashboard); + 40 | let searchCalled = false; + 41 | await page.route('**/api/search**', () => { searchCalled = true; }); + 42 | + 43 | await page.getByRole('searchbox').fill('U'); + 44 | await page.waitForTimeout(400); // debounce window + 45 | + 46 | expect(searchCalled).toBe(false); + 47 | }); + 48 | + 49 | test('typing 2+ characters triggers search with debounce', async ({ page }) => { + 50 | await gotoRoute(page, ROUTES.dashboard); + 51 | const searchBox = page.getByRole('searchbox'); + 52 | await searchBox.fill('User'); + 53 | + 54 | // Wait for debounce (300ms) + render + 55 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 56 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 57 | }); + 58 | + 59 | test('search results show correct names and kinds', async ({ page }) => { + 60 | await gotoRoute(page, ROUTES.dashboard); + 61 | await page.getByRole('searchbox').fill('User'); + 62 | + 63 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 64 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 65 | + 66 | await expect(dropdown.getByText('UserService')).toBeVisible(); + 67 | await expect(dropdown.getByText('findById')).toBeVisible(); + 68 | await expect(dropdown.getByText('GET /users/{id}')).toBeVisible(); + 69 | }); + 70 | + 71 | test('clicking a result navigates to the Explorer view', async ({ page }) => { + 72 | await gotoRoute(page, ROUTES.dashboard); + 73 | await page.getByRole('searchbox').fill('User'); + 74 | + 75 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 76 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 77 | await dropdown.getByText('UserService').click(); + 78 | + 79 | // Should navigate to explorer with the selected node + 80 | await expect(page).toHaveURL(/\/explorer/); + 81 | }); + 82 | + 83 | test('pressing Escape clears search dropdown', async ({ page }) => { + 84 | await gotoRoute(page, ROUTES.dashboard); + 85 | await page.getByRole('searchbox').fill('User'); + 86 | + 87 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 88 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 89 | + 90 | await page.keyboard.press('Escape'); + 91 | await expect(dropdown).not.toBeVisible(); + 92 | }); + 93 | + 94 | test('clicking outside search closes the dropdown', async ({ page }) => { + 95 | await gotoRoute(page, ROUTES.dashboard); + 96 | await page.getByRole('searchbox').fill('User'); + 97 | + 98 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 99 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 100 | + 101 | // Click somewhere outside the search bar + 102 | await page.locator('main').click({ position: { x: 10, y: 10 }, force: true }); + 103 | await expect(dropdown).not.toBeVisible(); + 104 | }); + 105 | + 106 | test('keyboard navigation in search results (ArrowDown / Enter)', async ({ page }) => { + 107 | await gotoRoute(page, ROUTES.dashboard); + 108 | const searchBox = page.getByRole('searchbox'); + 109 | await searchBox.fill('User'); + 110 | + 111 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 112 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 113 | + 114 | // Navigate with ArrowDown and select with Enter + 115 | await page.keyboard.press('ArrowDown'); + 116 | await page.keyboard.press('Enter'); + 117 | + 118 | // Should have navigated +> 119 | await expect(page).toHaveURL(/\/explorer|\/graph/); + | ^ Error: expect(page).toHaveURL(expected) failed + 120 | }); + 121 | + 122 | test('loading indicator shows while search is in progress', async ({ page }) => { + 123 | // Slow down the search response to see the loading state + 124 | await page.route('**/api/search**', async route => { + 125 | await new Promise(resolve => setTimeout(resolve, 300)); + 126 | await route.fulfill({ + 127 | status: 200, + 128 | contentType: 'application/json', + 129 | body: JSON.stringify(MOCK_SEARCH_RESULTS), + 130 | }); + 131 | }); + 132 | + 133 | await gotoRoute(page, ROUTES.dashboard); + 134 | await page.getByRole('searchbox').fill('User'); + 135 | + 136 | // Loading indicator should appear briefly + 137 | const spinner = page.locator('[data-testid="search-spinner"]'); + 138 | await expect(spinner).toBeVisible({ timeout: 500 }); + 139 | }); + 140 | + 141 | test('empty search results shows "no results" message', async ({ page }) => { + 142 | await page.route('**/api/search**', route => + 143 | route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) + 144 | ); + 145 | + 146 | await gotoRoute(page, ROUTES.dashboard); + 147 | await page.getByRole('searchbox').fill('xyznonexistent'); + 148 | + 149 | const dropdown = page.locator('[data-testid="search-dropdown"]'); + 150 | await expect(dropdown).toBeVisible({ timeout: 1000 }); + 151 | await expect(dropdown).toContainText(/no results/i); + 152 | }); + 153 | }); + 154 | + 155 | // ── File tree filtering (Phase 2 Frontend) ──────────────────────────────────── + 156 | + 157 | test.describe('File tree search integration', () => { + 158 | test.beforeEach(async ({ page }) => { + 159 | await mockStats(page); + 160 | await page.route('**/api/file-tree**', route => + 161 | route.fulfill({ + 162 | status: 200, + 163 | contentType: 'application/json', + 164 | body: JSON.stringify({ + 165 | name: 'root', + 166 | children: [ + 167 | { name: 'src', children: [ + 168 | { name: 'main', children: [ + 169 | { name: 'java', children: [ + 170 | { name: 'UserService.java', nodeCount: 5 }, + 171 | { name: 'UserController.java', nodeCount: 3 }, + 172 | ]}, + 173 | ]}, + 174 | ]}, + 175 | ], + 176 | }), + 177 | }) + 178 | ); + 179 | }); + 180 | + 181 | test('typing in search filters the file tree', async ({ page }) => { + 182 | await gotoRoute(page, ROUTES.explorer); + 183 | await page.getByRole('searchbox').fill('UserService'); + 184 | + 185 | // File tree should filter to show only matching files + 186 | const tree = page.locator('[data-testid="file-tree"]'); + 187 | if (await tree.isVisible()) { + 188 | await expect(tree.getByText('UserService.java')).toBeVisible(); + 189 | // Non-matching file should be hidden + 190 | await expect(tree.getByText('UserController.java')).not.toBeVisible(); + 191 | } + 192 | }); + 193 | }); + 194 | +``` \ No newline at end of file diff --git a/src/main/frontend/playwright-report/data/b2072c8aa5cd97f91093ec8cad3e5f7b48b55c66.md b/src/main/frontend/playwright-report/data/b2072c8aa5cd97f91093ec8cad3e5f7b48b55c66.md new file mode 100644 index 00000000..eac5b650 --- /dev/null +++ b/src/main/frontend/playwright-report/data/b2072c8aa5cd97f91093ec8cad3e5f7b48b55c66.md @@ -0,0 +1,222 @@ +# Instructions + +- Following Playwright test failed. +- Explain why, be concise, respect Playwright best practices. +- Provide a snippet of code with the fix, if possible. + +# Test info + +- Name: search.spec.ts >> File tree search integration >> typing in search filters the file tree +- Location: tests/e2e/search.spec.ts:181:3 + +# Error details + +``` +Test timeout of 30000ms exceeded. +``` + +``` +TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. +Call log: + - waiting for locator('main') to be visible + +``` + +# Page snapshot + +```yaml +- generic [ref=e3]: + - heading "Something went wrong" [level=1] [ref=e4] + - paragraph [ref=e5]: Cannot read properties of undefined (reading 'toLocaleString') + - button "Reload page" [ref=e6] [cursor=pointer] +``` + +# Test source + +```ts + 1 | /// + 2 | import { type Page, expect } from '@playwright/test'; + 3 | import { readFileSync, existsSync } from 'node:fs'; + 4 | import { resolve } from 'node:path'; + 5 | + 6 | // ── Route helpers ──────────────────────────────────────────────────────────── + 7 | + 8 | export const ROUTES = { + 9 | dashboard: '/', + 10 | graph: '/graph', + 11 | explorer: '/explorer', + 12 | console: '/console', + 13 | apiDocs: '/api-docs', + 14 | } as const; + 15 | + 16 | export type AppRoute = (typeof ROUTES)[keyof typeof ROUTES]; + 17 | + 18 | /** + 19 | * Intercept the HTML shell served by Spring Boot and replace it with the + 20 | * current on-disk version. The running JAR may contain a stale index.html + 21 | * (built before the last frontend rebuild), causing it to load an old + 22 | * JS bundle that crashes before React mounts. + 23 | * + 24 | * Bug: STALE_BUNDLE — tracked in RAN-80 (filed separately). + 25 | */ + 26 | export async function patchIndexHtml(page: Page) { + 27 | // process.cwd() is the frontend dir when running `npx playwright test` + 28 | const staticDir = resolve(process.cwd(), '../resources/static'); + 29 | const diskHtml = readFileSync(resolve(staticDir, 'index.html'), 'utf-8'); + 30 | + 31 | const CONTENT_TYPES: Record = { + 32 | '.js': 'application/javascript', + 33 | '.mjs': 'application/javascript', + 34 | '.css': 'text/css', + 35 | '.svg': 'image/svg+xml', + 36 | '.png': 'image/png', + 37 | '.ico': 'image/x-icon', + 38 | '.woff2': 'font/woff2', + 39 | '.woff': 'font/woff', + 40 | }; + 41 | + 42 | // Intercept the SPA shell route (all navigation routes return the same HTML) + 43 | await page.route('**/*', async (route) => { + 44 | const req = route.request(); + 45 | const url = req.url(); + 46 | + 47 | // Serve HTML shell from disk + 48 | if ( + 49 | req.resourceType() === 'document' && + 50 | !url.includes('/api/') && + 51 | !url.includes('/swagger') && + 52 | !url.includes('/v3/') + 53 | ) { + 54 | await route.fulfill({ status: 200, contentType: 'text/html', body: diskHtml }); + 55 | return; + 56 | } + 57 | + 58 | // Serve static assets from disk if available (fixes stale-JAR bundle mismatch) + 59 | const assetMatch = url.match(/\/assets\/([^?#]+)/); + 60 | if (assetMatch) { + 61 | const assetName = assetMatch[1]; + 62 | const diskPath = resolve(staticDir, 'assets', assetName); + 63 | const ext = assetName.includes('.') ? '.' + assetName.split('.').pop()! : ''; + 64 | if (existsSync(diskPath)) { + 65 | const body = readFileSync(diskPath); + 66 | await route.fulfill({ + 67 | status: 200, + 68 | contentType: CONTENT_TYPES[ext] ?? 'application/octet-stream', + 69 | body, + 70 | }); + 71 | return; + 72 | } + 73 | } + 74 | + 75 | await route.fallback(); + 76 | }); + 77 | } + 78 | + 79 | /** Navigate to a route and wait for the main content area to be visible. */ + 80 | export async function gotoRoute(page: Page, route: AppRoute) { + 81 | await patchIndexHtml(page); + 82 | await page.goto(route); + 83 | // Wait for React to hydrate (main rendered by Layout component) +> 84 | await page.waitForSelector('main', { state: 'visible', timeout: 30000 }); + | ^ TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. + 85 | } + 86 | + 87 | // ── Theme helpers ──────────────────────────────────────────────────────────── + 88 | + 89 | /** Returns the current theme: 'dark' | 'light'. */ + 90 | export async function getTheme(page: Page): Promise<'dark' | 'light'> { + 91 | const cls = await page.locator('html').getAttribute('class') ?? ''; + 92 | return cls.includes('dark') ? 'dark' : 'light'; + 93 | } + 94 | + 95 | /** Click the theme toggle and wait for the class to flip. */ + 96 | export async function toggleTheme(page: Page) { + 97 | const before = await getTheme(page); + 98 | // Theme toggle button — uses aria-label or data-testid set by the component + 99 | await page.getByRole('button', { name: /toggle theme|switch theme|dark mode|light mode/i }).click(); + 100 | await expect(page.locator('html')).toHaveClass(before === 'dark' ? /light/ : /dark/, { timeout: 2000 }); + 101 | } + 102 | + 103 | // ── API mock helpers ───────────────────────────────────────────────────────── + 104 | + 105 | /** Seed the `/api/stats` mock for deterministic dashboard tests. */ + 106 | export async function mockStats(page: Page, nodeCount = 1234, edgeCount = 5678) { + 107 | await page.route('**/api/stats', route => + 108 | route.fulfill({ + 109 | status: 200, + 110 | contentType: 'application/json', + 111 | body: JSON.stringify({ + 112 | totalNodes: nodeCount, + 113 | totalEdges: edgeCount, + 114 | nodesByKind: { endpoint: 10, class: 20, method: 30 }, + 115 | edgesByKind: { calls: 100, depends_on: 50 }, + 116 | languages: { java: 500, typescript: 200 }, + 117 | frameworks: { spring_boot: 300 }, + 118 | layers: { backend: 600, frontend: 200, infra: 100, shared: 50, unknown: 284 }, + 119 | }), + 120 | }) + 121 | ); + 122 | } + 123 | + 124 | /** + 125 | * Generate a synthetic node list for performance/stress tests. + 126 | * Returns a NodesListResponse-shaped object. + 127 | */ + 128 | export function generateNodeList(count: number) { + 129 | const nodes = Array.from({ length: count }, (_, i) => ({ + 130 | id: `node:file${i % 100}.ts:class:Class${i}`, + 131 | kind: ['class', 'method', 'endpoint', 'entity', 'function'][i % 5], + 132 | name: `Symbol${i}`, + 133 | qualifiedName: `com.example.Symbol${i}`, + 134 | filePath: `src/file${i % 100}.ts`, + 135 | layer: 'backend', + 136 | framework: null, + 137 | properties: {}, + 138 | })); + 139 | return { nodes, total: count, offset: 0, limit: count }; + 140 | } + 141 | + 142 | /** Seed the `/api/kinds` + `/api/nodes` endpoints with synthetic data. */ + 143 | export async function mockGraphData(page: Page, nodeCount: number) { + 144 | const data = generateNodeList(nodeCount); + 145 | + 146 | await page.route('**/api/kinds', route => + 147 | route.fulfill({ + 148 | status: 200, + 149 | contentType: 'application/json', + 150 | body: JSON.stringify({ + 151 | kinds: [ + 152 | { kind: 'class', count: Math.floor(nodeCount * 0.3) }, + 153 | { kind: 'method', count: Math.floor(nodeCount * 0.3) }, + 154 | { kind: 'endpoint', count: Math.floor(nodeCount * 0.15) }, + 155 | { kind: 'entity', count: Math.floor(nodeCount * 0.15) }, + 156 | { kind: 'function', count: Math.floor(nodeCount * 0.1) }, + 157 | ], + 158 | }), + 159 | }) + 160 | ); + 161 | + 162 | await page.route('**/api/nodes**', route => + 163 | route.fulfill({ + 164 | status: 200, + 165 | contentType: 'application/json', + 166 | body: JSON.stringify(data), + 167 | }) + 168 | ); + 169 | + 170 | await page.route('**/api/topology', route => + 171 | route.fulfill({ + 172 | status: 200, + 173 | contentType: 'application/json', + 174 | body: JSON.stringify({ + 175 | services: [ + 176 | { name: 'api-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['db-service'] }, + 177 | { name: 'db-service', nodeCount: Math.floor(nodeCount / 3), dependencies: [] }, + 178 | { name: 'frontend-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['api-service'] }, + 179 | ], + 180 | }), + 181 | }) + 182 | ); + 183 | } + 184 | +``` \ No newline at end of file diff --git a/src/main/frontend/playwright-report/data/f1d00512fbfc05f51c50987f6e805c0156f31b2d.png b/src/main/frontend/playwright-report/data/f1d00512fbfc05f51c50987f6e805c0156f31b2d.png new file mode 100644 index 00000000..2e81505d Binary files /dev/null and b/src/main/frontend/playwright-report/data/f1d00512fbfc05f51c50987f6e805c0156f31b2d.png differ diff --git a/src/main/frontend/playwright-report/data/f6eb10ff67df8fe6b39c93a7519699a0d4e67b55.webm b/src/main/frontend/playwright-report/data/f6eb10ff67df8fe6b39c93a7519699a0d4e67b55.webm new file mode 100644 index 00000000..a092c5ee Binary files /dev/null and b/src/main/frontend/playwright-report/data/f6eb10ff67df8fe6b39c93a7519699a0d4e67b55.webm differ diff --git a/src/main/frontend/playwright-report/index.html b/src/main/frontend/playwright-report/index.html index 44aea3e0..d3c4cd5f 100644 --- a/src/main/frontend/playwright-report/index.html +++ b/src/main/frontend/playwright-report/index.html @@ -87,4 +87,4 @@
- \ No newline at end of file + \ No newline at end of file diff --git a/src/main/frontend/test-results/.last-run.json b/src/main/frontend/test-results/.last-run.json new file mode 100644 index 00000000..cbcc1fba --- /dev/null +++ b/src/main/frontend/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/src/main/frontend/test-results/.playwright-artifacts-16/page@333818398abd7970554a5643c2e3eeb2.webm b/src/main/frontend/test-results/.playwright-artifacts-16/page@333818398abd7970554a5643c2e3eeb2.webm deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/frontend/test-results/.playwright-artifacts-17/page@1643517e96d910eac709a898d06616e6.webm b/src/main/frontend/test-results/.playwright-artifacts-17/page@1643517e96d910eac709a898d06616e6.webm deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/error-context.md deleted file mode 100644 index d7cc8b9a..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/error-context.md +++ /dev/null @@ -1,134 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> accessibility: tree has correct ARIA roles -- Location: tests/e2e/file-tree.spec.ts:252:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { -> 253 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/video.webm deleted file mode 100644 index 78cda285..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-0093f-tree-has-correct-ARIA-roles-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/error-context.md deleted file mode 100644 index e494b664..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/error-context.md +++ /dev/null @@ -1,150 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> clearing file filter on graph view removes badge -- Location: tests/e2e/file-tree.spec.ts:236:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { -> 237 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/video.webm deleted file mode 100644 index 77657540..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-0398c-on-graph-view-removes-badge-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/error-context.md deleted file mode 100644 index f1c3bf43..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/error-context.md +++ /dev/null @@ -1,185 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> keyboard navigation: ArrowDown moves focus -- Location: tests/e2e/file-tree.spec.ts:201:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { -> 202 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/video.webm deleted file mode 100644 index b1064e04..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-2bb63-ation-ArrowDown-moves-focus-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/error-context.md deleted file mode 100644 index 8f5f130a..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/error-context.md +++ /dev/null @@ -1,173 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> keyboard navigation: Enter selects and navigates -- Location: tests/e2e/file-tree.spec.ts:213:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { -> 214 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/video.webm deleted file mode 100644 index 75016b4b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-36508-Enter-selects-and-navigates-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/error-context.md deleted file mode 100644 index 1a22a3b9..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/error-context.md +++ /dev/null @@ -1,160 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> hides file tree when sidebar is collapsed -- Location: tests/e2e/file-tree.spec.ts:226:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { -> 227 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/video.webm deleted file mode 100644 index b5268646..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-50ba7-e-when-sidebar-is-collapsed-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/error-context.md deleted file mode 100644 index f2e736ec..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/error-context.md +++ /dev/null @@ -1,226 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> collapses expanded directory on second click -- Location: tests/e2e/file-tree.spec.ts:132:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 33 | type: 'file', - 34 | nodeCount: 3, - 35 | }, - 36 | ], - 37 | }, - 38 | { - 39 | name: 'components', - 40 | path: 'src/components', - 41 | type: 'directory', - 42 | nodeCount: 20, - 43 | children: [ - 44 | { - 45 | name: 'Button.tsx', - 46 | path: 'src/components/Button.tsx', - 47 | type: 'file', - 48 | nodeCount: 5, - 49 | }, - 50 | ], - 51 | }, - 52 | ], - 53 | }, - 54 | { - 55 | name: 'pom.xml', - 56 | path: 'pom.xml', - 57 | type: 'file', - 58 | nodeCount: 2, - 59 | }, - 60 | ], - 61 | }; - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { -> 133 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/video.webm deleted file mode 100644 index 58323b59..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-58a5d-d-directory-on-second-click-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/error-context.md deleted file mode 100644 index 68d29979..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/error-context.md +++ /dev/null @@ -1,226 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> shows root directory expanded by default -- Location: tests/e2e/file-tree.spec.ts:112:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 13 | name: 'src', - 14 | path: 'src', - 15 | type: 'directory', - 16 | nodeCount: 80, - 17 | children: [ - 18 | { - 19 | name: 'main', - 20 | path: 'src/main', - 21 | type: 'directory', - 22 | nodeCount: 60, - 23 | children: [ - 24 | { - 25 | name: 'App.tsx', - 26 | path: 'src/main/App.tsx', - 27 | type: 'file', - 28 | nodeCount: 12, - 29 | }, - 30 | { - 31 | name: 'index.ts', - 32 | path: 'src/main/index.ts', - 33 | type: 'file', - 34 | nodeCount: 3, - 35 | }, - 36 | ], - 37 | }, - 38 | { - 39 | name: 'components', - 40 | path: 'src/components', - 41 | type: 'directory', - 42 | nodeCount: 20, - 43 | children: [ - 44 | { - 45 | name: 'Button.tsx', - 46 | path: 'src/components/Button.tsx', - 47 | type: 'file', - 48 | nodeCount: 5, - 49 | }, - 50 | ], - 51 | }, - 52 | ], - 53 | }, - 54 | { - 55 | name: 'pom.xml', - 56 | path: 'pom.xml', - 57 | type: 'file', - 58 | nodeCount: 2, - 59 | }, - 60 | ], - 61 | }; - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { -> 113 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/video.webm deleted file mode 100644 index 137d74f2..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-9e7a2-rectory-expanded-by-default-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/error-context.md deleted file mode 100644 index d08c3d34..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/error-context.md +++ /dev/null @@ -1,214 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> shows "no match" message for unmatched query -- Location: tests/e2e/file-tree.spec.ts:172:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { -> 173 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/video.webm deleted file mode 100644 index 32cb7323..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-a07f8-message-for-unmatched-query-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/error-context.md deleted file mode 100644 index a1c7cf94..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/error-context.md +++ /dev/null @@ -1,197 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> navigates to graph view on file click -- Location: tests/e2e/file-tree.spec.ts:189:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { -> 190 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/video.webm deleted file mode 100644 index 25687c76..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tre-b3d13-to-graph-view-on-file-click-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/error-context.md deleted file mode 100644 index bd7f5f96..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/error-context.md +++ /dev/null @@ -1,225 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> clears search with X button -- Location: tests/e2e/file-tree.spec.ts:161:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { -> 162 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/video.webm deleted file mode 100644 index 40b06050..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tree-clears-search-with-X-button-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/error-context.md deleted file mode 100644 index 7ec52912..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/error-context.md +++ /dev/null @@ -1,226 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> expands directory on click -- Location: tests/e2e/file-tree.spec.ts:121:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 22 | nodeCount: 60, - 23 | children: [ - 24 | { - 25 | name: 'App.tsx', - 26 | path: 'src/main/App.tsx', - 27 | type: 'file', - 28 | nodeCount: 12, - 29 | }, - 30 | { - 31 | name: 'index.ts', - 32 | path: 'src/main/index.ts', - 33 | type: 'file', - 34 | nodeCount: 3, - 35 | }, - 36 | ], - 37 | }, - 38 | { - 39 | name: 'components', - 40 | path: 'src/components', - 41 | type: 'directory', - 42 | nodeCount: 20, - 43 | children: [ - 44 | { - 45 | name: 'Button.tsx', - 46 | path: 'src/components/Button.tsx', - 47 | type: 'file', - 48 | nodeCount: 5, - 49 | }, - 50 | ], - 51 | }, - 52 | ], - 53 | }, - 54 | { - 55 | name: 'pom.xml', - 56 | path: 'pom.xml', - 57 | type: 'file', - 58 | nodeCount: 2, - 59 | }, - 60 | ], - 61 | }; - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { -> 122 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/video.webm deleted file mode 100644 index 9be5193c..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tree-expands-directory-on-click-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/error-context.md deleted file mode 100644 index 19b95889..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/error-context.md +++ /dev/null @@ -1,226 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> filters tree with search input -- Location: tests/e2e/file-tree.spec.ts:145:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 46 | path: 'src/components/Button.tsx', - 47 | type: 'file', - 48 | nodeCount: 5, - 49 | }, - 50 | ], - 51 | }, - 52 | ], - 53 | }, - 54 | { - 55 | name: 'pom.xml', - 56 | path: 'pom.xml', - 57 | type: 'file', - 58 | nodeCount: 2, - 59 | }, - 60 | ], - 61 | }; - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { -> 146 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/video.webm deleted file mode 100644 index 0b489a3b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tree-filters-tree-with-search-input-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/error-context.md deleted file mode 100644 index 70569476..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/error-context.md +++ /dev/null @@ -1,226 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> renders file tree in sidebar -- Location: tests/e2e/file-tree.spec.ts:105:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 6 | const MOCK_FILE_TREE = { - 7 | name: 'root', - 8 | path: '', - 9 | type: 'directory', - 10 | nodeCount: 100, - 11 | children: [ - 12 | { - 13 | name: 'src', - 14 | path: 'src', - 15 | type: 'directory', - 16 | nodeCount: 80, - 17 | children: [ - 18 | { - 19 | name: 'main', - 20 | path: 'src/main', - 21 | type: 'directory', - 22 | nodeCount: 60, - 23 | children: [ - 24 | { - 25 | name: 'App.tsx', - 26 | path: 'src/main/App.tsx', - 27 | type: 'file', - 28 | nodeCount: 12, - 29 | }, - 30 | { - 31 | name: 'index.ts', - 32 | path: 'src/main/index.ts', - 33 | type: 'file', - 34 | nodeCount: 3, - 35 | }, - 36 | ], - 37 | }, - 38 | { - 39 | name: 'components', - 40 | path: 'src/components', - 41 | type: 'directory', - 42 | nodeCount: 20, - 43 | children: [ - 44 | { - 45 | name: 'Button.tsx', - 46 | path: 'src/components/Button.tsx', - 47 | type: 'file', - 48 | nodeCount: 5, - 49 | }, - 50 | ], - 51 | }, - 52 | ], - 53 | }, - 54 | { - 55 | name: 'pom.xml', - 56 | path: 'pom.xml', - 57 | type: 'file', - 58 | nodeCount: 2, - 59 | }, - 60 | ], - 61 | }; - 62 | - 63 | async function mockFileTree(page: Page) { - 64 | await page.route('**/api/file-tree**', route => - 65 | route.fulfill({ - 66 | status: 200, - 67 | contentType: 'application/json', - 68 | body: JSON.stringify(MOCK_FILE_TREE), - 69 | }), - 70 | ); - 71 | } - 72 | - 73 | async function mockMinimalApis(page: Page) { - 74 | await page.route('**/api/stats**', route => - 75 | route.fulfill({ - 76 | status: 200, - 77 | contentType: 'application/json', - 78 | body: JSON.stringify({ node_count: 100, edge_count: 200, nodes_by_kind: {}, nodes_by_layer: {} }), - 79 | }), - 80 | ); - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { -> 106 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { - 181 | await page.goto('/'); - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/video.webm deleted file mode 100644 index 189bb8f8..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tree-renders-file-tree-in-sidebar-chromium/video.webm and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/error-context.md b/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/error-context.md deleted file mode 100644 index ca62cb23..00000000 --- a/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/error-context.md +++ /dev/null @@ -1,206 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: file-tree.spec.ts >> Project File Tree >> shows node count badges -- Location: tests/e2e/file-tree.spec.ts:180:3 - -# Error details - -``` -Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ -Call log: - - navigating to "http://localhost:8080/", waiting until "load" - -``` - -# Test source - -```ts - 81 | await page.route('**/api/kinds**', route => - 82 | route.fulfill({ - 83 | status: 200, - 84 | contentType: 'application/json', - 85 | body: JSON.stringify({ kinds: [{ kind: 'class', count: 50 }, { kind: 'method', count: 50 }], total: 100 }), - 86 | }), - 87 | ); - 88 | await page.route('**/api/nodes**', route => - 89 | route.fulfill({ - 90 | status: 200, - 91 | contentType: 'application/json', - 92 | body: JSON.stringify({ nodes: [], total: 0, offset: 0, limit: 50 }), - 93 | }), - 94 | ); - 95 | } - 96 | - 97 | /* ── Tests ────────────────────────────────────────────────────────── */ - 98 | - 99 | test.describe('Project File Tree', () => { - 100 | test.beforeEach(async ({ page }) => { - 101 | await mockFileTree(page); - 102 | await mockMinimalApis(page); - 103 | }); - 104 | - 105 | test('renders file tree in sidebar', async ({ page }) => { - 106 | await page.goto('/'); - 107 | await page.waitForSelector('[aria-label="Project file tree"]'); - 108 | await expect(page.getByText('Project Files')).toBeVisible(); - 109 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 110 | }); - 111 | - 112 | test('shows root directory expanded by default', async ({ page }) => { - 113 | await page.goto('/'); - 114 | await page.waitForSelector('[aria-label="Project file tree"]'); - 115 | // src directory should be visible (root auto-expanded) - 116 | await expect(page.getByText('src')).toBeVisible(); - 117 | // pom.xml should be visible (top-level file) - 118 | await expect(page.getByText('pom.xml')).toBeVisible(); - 119 | }); - 120 | - 121 | test('expands directory on click', async ({ page }) => { - 122 | await page.goto('/'); - 123 | await page.waitForSelector('[aria-label="Project file tree"]'); - 124 | - 125 | // 'main' subdirectory is inside 'src' which is inside root - 126 | // Click 'src' to expand it - 127 | await page.getByText('src').click(); - 128 | await expect(page.getByText('main')).toBeVisible(); - 129 | await expect(page.getByText('components')).toBeVisible(); - 130 | }); - 131 | - 132 | test('collapses expanded directory on second click', async ({ page }) => { - 133 | await page.goto('/'); - 134 | await page.waitForSelector('[aria-label="Project file tree"]'); - 135 | - 136 | // Click src to expand - 137 | await page.getByText('src').click(); - 138 | await expect(page.getByText('main')).toBeVisible(); - 139 | - 140 | // Click src again to collapse - 141 | await page.getByText('src').click(); - 142 | await expect(page.getByText('main')).not.toBeVisible(); - 143 | }); - 144 | - 145 | test('filters tree with search input', async ({ page }) => { - 146 | await page.goto('/'); - 147 | await page.waitForSelector('[aria-label="Project file tree"]'); - 148 | - 149 | // Expand src to make files visible - 150 | await page.getByText('src').click(); - 151 | await page.getByText('main').click(); - 152 | - 153 | const searchInput = page.getByPlaceholder('Filter files…'); - 154 | await searchInput.fill('App'); - 155 | - 156 | // Only App.tsx should be visible, not index.ts - 157 | await expect(page.getByText('App.tsx')).toBeVisible(); - 158 | await expect(page.getByText('index.ts')).not.toBeVisible(); - 159 | }); - 160 | - 161 | test('clears search with X button', async ({ page }) => { - 162 | await page.goto('/'); - 163 | await page.waitForSelector('[aria-label="Project file tree"]'); - 164 | - 165 | const searchInput = page.getByPlaceholder('Filter files…'); - 166 | await searchInput.fill('App'); - 167 | - 168 | await page.getByRole('button', { name: 'Clear filter' }).click(); - 169 | await expect(searchInput).toHaveValue(''); - 170 | }); - 171 | - 172 | test('shows "no match" message for unmatched query', async ({ page }) => { - 173 | await page.goto('/'); - 174 | await page.waitForSelector('[aria-label="Project file tree"]'); - 175 | - 176 | await page.getByPlaceholder('Filter files…').fill('xyznotfound'); - 177 | await expect(page.getByText(/No files match/)).toBeVisible(); - 178 | }); - 179 | - 180 | test('shows node count badges', async ({ page }) => { -> 181 | await page.goto('/'); - | ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:8080/ - 182 | await page.waitForSelector('[aria-label="Project file tree"]'); - 183 | - 184 | // pom.xml has nodeCount: 2, should show badge - 185 | const pomRow = page.getByTestId('tree-node-pom.xml'); - 186 | await expect(pomRow).toContainText('2'); - 187 | }); - 188 | - 189 | test('navigates to graph view on file click', async ({ page }) => { - 190 | await page.goto('/'); - 191 | await page.waitForSelector('[aria-label="Project file tree"]'); - 192 | - 193 | // Click pom.xml (visible at root level) - 194 | await page.getByTestId('tree-node-pom.xml').click(); - 195 | - 196 | await expect(page).toHaveURL(/\/graph/); - 197 | await expect(page.getByTestId('file-filter-badge')).toBeVisible(); - 198 | await expect(page.getByTestId('file-filter-badge')).toContainText('pom.xml'); - 199 | }); - 200 | - 201 | test('keyboard navigation: ArrowDown moves focus', async ({ page }) => { - 202 | await page.goto('/'); - 203 | await page.waitForSelector('[aria-label="Project file tree"]'); - 204 | - 205 | // Focus the tree - 206 | const tree = page.getByRole('tree', { name: 'Project file tree' }); - 207 | await tree.press('ArrowDown'); - 208 | // Check that a treeitem receives focus - 209 | const focused = page.locator('[role="treeitem"]:focus'); - 210 | await expect(focused).toHaveCount(1); - 211 | }); - 212 | - 213 | test('keyboard navigation: Enter selects and navigates', async ({ page }) => { - 214 | await page.goto('/'); - 215 | await page.waitForSelector('[aria-label="Project file tree"]'); - 216 | - 217 | // Focus first treeitem and press Enter - 218 | const firstItem = page.locator('[role="treeitem"]').first(); - 219 | await firstItem.focus(); - 220 | await firstItem.press('Enter'); - 221 | - 222 | // Should navigate to /graph - 223 | await expect(page).toHaveURL(/\/graph/); - 224 | }); - 225 | - 226 | test('hides file tree when sidebar is collapsed', async ({ page }) => { - 227 | await page.goto('/'); - 228 | await page.waitForSelector('[aria-label="Project file tree"]'); - 229 | - 230 | // Collapse the sidebar - 231 | await page.getByRole('button', { name: /collapse sidebar/i }).click(); - 232 | - 233 | await expect(page.getByRole('tree', { name: 'Project file tree' })).not.toBeVisible(); - 234 | }); - 235 | - 236 | test('clearing file filter on graph view removes badge', async ({ page }) => { - 237 | await page.goto('/'); - 238 | await page.waitForSelector('[aria-label="Project file tree"]'); - 239 | - 240 | // Navigate via file click - 241 | await page.getByTestId('tree-node-pom.xml').click(); - 242 | await expect(page).toHaveURL(/\/graph/); - 243 | - 244 | const badge = page.getByTestId('file-filter-badge'); - 245 | await expect(badge).toBeVisible(); - 246 | - 247 | // Click X on the badge - 248 | await page.getByRole('button', { name: 'Clear file filter' }).click(); - 249 | await expect(badge).not.toBeVisible(); - 250 | }); - 251 | - 252 | test('accessibility: tree has correct ARIA roles', async ({ page }) => { - 253 | await page.goto('/'); - 254 | await page.waitForSelector('[aria-label="Project file tree"]'); - 255 | - 256 | await expect(page.getByRole('tree', { name: 'Project file tree' })).toBeVisible(); - 257 | const items = page.locator('[role="treeitem"]'); - 258 | await expect(items).not.toHaveCount(0); - 259 | }); - 260 | }); - 261 | -``` \ No newline at end of file diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/test-failed-1.png b/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/test-failed-1.png deleted file mode 100644 index 3ddab50b..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/test-failed-1.png and /dev/null differ diff --git a/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/video.webm b/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/video.webm deleted file mode 100644 index 6a7273b9..00000000 Binary files a/src/main/frontend/test-results/file-tree-Project-File-Tree-shows-node-count-badges-chromium/video.webm and /dev/null differ diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/error-context.md" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/error-context.md" deleted file mode 100644 index 7008b94d..00000000 --- "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/error-context.md" +++ /dev/null @@ -1,189 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: graph.spec.ts >> Graph view — initial render >> controls toolbar is visible -- Location: tests/e2e/graph.spec.ts:30:3 - -# Error details - -``` -Test timeout of 30000ms exceeded while running "beforeEach" hook. -``` - -``` -TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. -Call log: - - waiting for locator('main') to be visible - -``` - -# Test source - -```ts - 1 | /// - 2 | import { type Page, expect } from '@playwright/test'; - 3 | import { readFileSync } from 'node:fs'; - 4 | import { resolve } from 'node:path'; - 5 | - 6 | // ── Route helpers ──────────────────────────────────────────────────────────── - 7 | - 8 | export const ROUTES = { - 9 | dashboard: '/', - 10 | graph: '/graph', - 11 | explorer: '/explorer', - 12 | console: '/console', - 13 | apiDocs: '/api-docs', - 14 | } as const; - 15 | - 16 | export type AppRoute = (typeof ROUTES)[keyof typeof ROUTES]; - 17 | - 18 | /** - 19 | * Intercept the HTML shell served by Spring Boot and replace it with the - 20 | * current on-disk version. The running JAR may contain a stale index.html - 21 | * (built before the last frontend rebuild), causing it to load an old - 22 | * JS bundle that crashes before React mounts. - 23 | * - 24 | * Bug: STALE_BUNDLE — tracked in RAN-80 (filed separately). - 25 | */ - 26 | export async function patchIndexHtml(page: Page) { - 27 | // process.cwd() is the frontend dir when running `npx playwright test` - 28 | const diskHtml = readFileSync( - 29 | resolve(process.cwd(), '../resources/static/index.html'), - 30 | 'utf-8', - 31 | ); - 32 | // Intercept the SPA shell route (all navigation routes return the same HTML) - 33 | await page.route('**/*', async (route) => { - 34 | const req = route.request(); - 35 | const url = req.url(); - 36 | // Only intercept HTML document requests (the SPA shell), not API/asset calls - 37 | if ( - 38 | req.resourceType() === 'document' && - 39 | !url.includes('/api/') && - 40 | !url.includes('/assets/') && - 41 | !url.includes('/swagger') && - 42 | !url.includes('/v3/') - 43 | ) { - 44 | await route.fulfill({ - 45 | status: 200, - 46 | contentType: 'text/html', - 47 | body: diskHtml, - 48 | }); - 49 | } else { - 50 | await route.continue(); - 51 | } - 52 | }); - 53 | } - 54 | - 55 | /** Navigate to a route and wait for the main content area to be visible. */ - 56 | export async function gotoRoute(page: Page, route: AppRoute) { - 57 | await patchIndexHtml(page); - 58 | await page.goto(route); - 59 | // Wait for React to hydrate (main rendered by Layout component) -> 60 | await page.waitForSelector('main', { state: 'visible', timeout: 30000 }); - | ^ TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. - 61 | } - 62 | - 63 | // ── Theme helpers ──────────────────────────────────────────────────────────── - 64 | - 65 | /** Returns the current theme: 'dark' | 'light'. */ - 66 | export async function getTheme(page: Page): Promise<'dark' | 'light'> { - 67 | const cls = await page.locator('html').getAttribute('class') ?? ''; - 68 | return cls.includes('dark') ? 'dark' : 'light'; - 69 | } - 70 | - 71 | /** Click the theme toggle and wait for the class to flip. */ - 72 | export async function toggleTheme(page: Page) { - 73 | const before = await getTheme(page); - 74 | // Theme toggle button — uses aria-label or data-testid set by the component - 75 | await page.getByRole('button', { name: /toggle theme|switch theme|dark mode|light mode/i }).click(); - 76 | await expect(page.locator('html')).toHaveClass(before === 'dark' ? /light/ : /dark/, { timeout: 2000 }); - 77 | } - 78 | - 79 | // ── API mock helpers ───────────────────────────────────────────────────────── - 80 | - 81 | /** Seed the `/api/stats` mock for deterministic dashboard tests. */ - 82 | export async function mockStats(page: Page, nodeCount = 1234, edgeCount = 5678) { - 83 | await page.route('**/api/stats', route => - 84 | route.fulfill({ - 85 | status: 200, - 86 | contentType: 'application/json', - 87 | body: JSON.stringify({ - 88 | totalNodes: nodeCount, - 89 | totalEdges: edgeCount, - 90 | nodesByKind: { endpoint: 10, class: 20, method: 30 }, - 91 | edgesByKind: { calls: 100, depends_on: 50 }, - 92 | languages: { java: 500, typescript: 200 }, - 93 | frameworks: { spring_boot: 300 }, - 94 | layers: { backend: 600, frontend: 200, infra: 100, shared: 50, unknown: 284 }, - 95 | }), - 96 | }) - 97 | ); - 98 | } - 99 | - 100 | /** - 101 | * Generate a synthetic node list for performance/stress tests. - 102 | * Returns a NodesListResponse-shaped object. - 103 | */ - 104 | export function generateNodeList(count: number) { - 105 | const nodes = Array.from({ length: count }, (_, i) => ({ - 106 | id: `node:file${i % 100}.ts:class:Class${i}`, - 107 | kind: ['class', 'method', 'endpoint', 'entity', 'function'][i % 5], - 108 | name: `Symbol${i}`, - 109 | qualifiedName: `com.example.Symbol${i}`, - 110 | filePath: `src/file${i % 100}.ts`, - 111 | layer: 'backend', - 112 | framework: null, - 113 | properties: {}, - 114 | })); - 115 | return { nodes, total: count, offset: 0, limit: count }; - 116 | } - 117 | - 118 | /** Seed the `/api/kinds` + `/api/nodes` endpoints with synthetic data. */ - 119 | export async function mockGraphData(page: Page, nodeCount: number) { - 120 | const data = generateNodeList(nodeCount); - 121 | - 122 | await page.route('**/api/kinds', route => - 123 | route.fulfill({ - 124 | status: 200, - 125 | contentType: 'application/json', - 126 | body: JSON.stringify({ - 127 | kinds: [ - 128 | { kind: 'class', count: Math.floor(nodeCount * 0.3) }, - 129 | { kind: 'method', count: Math.floor(nodeCount * 0.3) }, - 130 | { kind: 'endpoint', count: Math.floor(nodeCount * 0.15) }, - 131 | { kind: 'entity', count: Math.floor(nodeCount * 0.15) }, - 132 | { kind: 'function', count: Math.floor(nodeCount * 0.1) }, - 133 | ], - 134 | }), - 135 | }) - 136 | ); - 137 | - 138 | await page.route('**/api/nodes**', route => - 139 | route.fulfill({ - 140 | status: 200, - 141 | contentType: 'application/json', - 142 | body: JSON.stringify(data), - 143 | }) - 144 | ); - 145 | - 146 | await page.route('**/api/topology', route => - 147 | route.fulfill({ - 148 | status: 200, - 149 | contentType: 'application/json', - 150 | body: JSON.stringify({ - 151 | services: [ - 152 | { name: 'api-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['db-service'] }, - 153 | { name: 'db-service', nodeCount: Math.floor(nodeCount / 3), dependencies: [] }, - 154 | { name: 'frontend-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['api-service'] }, - 155 | ], - 156 | }), - 157 | }) - 158 | ); - 159 | } - 160 | -``` \ No newline at end of file diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/test-failed-1.png" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/test-failed-1.png" deleted file mode 100644 index 3ddab50b..00000000 Binary files "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/test-failed-1.png" and /dev/null differ diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/video.webm" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/video.webm" deleted file mode 100644 index dac5f70b..00000000 Binary files "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-4a1c2-controls-toolbar-is-visible-chromium/video.webm" and /dev/null differ diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-9271c-mb-shows-Level-0-landscape--chromium/test-failed-1.png" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-9271c-mb-shows-Level-0-landscape--chromium/test-failed-1.png" deleted file mode 100644 index 3ddab50b..00000000 Binary files "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-9271c-mb-shows-Level-0-landscape--chromium/test-failed-1.png" and /dev/null differ diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/error-context.md" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/error-context.md" deleted file mode 100644 index 524e2b3e..00000000 --- "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/error-context.md" +++ /dev/null @@ -1,189 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: graph.spec.ts >> Graph view — initial render >> graph container is visible -- Location: tests/e2e/graph.spec.ts:26:3 - -# Error details - -``` -Test timeout of 30000ms exceeded while running "beforeEach" hook. -``` - -``` -TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. -Call log: - - waiting for locator('main') to be visible - -``` - -# Test source - -```ts - 1 | /// - 2 | import { type Page, expect } from '@playwright/test'; - 3 | import { readFileSync } from 'node:fs'; - 4 | import { resolve } from 'node:path'; - 5 | - 6 | // ── Route helpers ──────────────────────────────────────────────────────────── - 7 | - 8 | export const ROUTES = { - 9 | dashboard: '/', - 10 | graph: '/graph', - 11 | explorer: '/explorer', - 12 | console: '/console', - 13 | apiDocs: '/api-docs', - 14 | } as const; - 15 | - 16 | export type AppRoute = (typeof ROUTES)[keyof typeof ROUTES]; - 17 | - 18 | /** - 19 | * Intercept the HTML shell served by Spring Boot and replace it with the - 20 | * current on-disk version. The running JAR may contain a stale index.html - 21 | * (built before the last frontend rebuild), causing it to load an old - 22 | * JS bundle that crashes before React mounts. - 23 | * - 24 | * Bug: STALE_BUNDLE — tracked in RAN-80 (filed separately). - 25 | */ - 26 | export async function patchIndexHtml(page: Page) { - 27 | // process.cwd() is the frontend dir when running `npx playwright test` - 28 | const diskHtml = readFileSync( - 29 | resolve(process.cwd(), '../resources/static/index.html'), - 30 | 'utf-8', - 31 | ); - 32 | // Intercept the SPA shell route (all navigation routes return the same HTML) - 33 | await page.route('**/*', async (route) => { - 34 | const req = route.request(); - 35 | const url = req.url(); - 36 | // Only intercept HTML document requests (the SPA shell), not API/asset calls - 37 | if ( - 38 | req.resourceType() === 'document' && - 39 | !url.includes('/api/') && - 40 | !url.includes('/assets/') && - 41 | !url.includes('/swagger') && - 42 | !url.includes('/v3/') - 43 | ) { - 44 | await route.fulfill({ - 45 | status: 200, - 46 | contentType: 'text/html', - 47 | body: diskHtml, - 48 | }); - 49 | } else { - 50 | await route.continue(); - 51 | } - 52 | }); - 53 | } - 54 | - 55 | /** Navigate to a route and wait for the main content area to be visible. */ - 56 | export async function gotoRoute(page: Page, route: AppRoute) { - 57 | await patchIndexHtml(page); - 58 | await page.goto(route); - 59 | // Wait for React to hydrate (main rendered by Layout component) -> 60 | await page.waitForSelector('main', { state: 'visible', timeout: 30000 }); - | ^ TimeoutError: page.waitForSelector: Timeout 30000ms exceeded. - 61 | } - 62 | - 63 | // ── Theme helpers ──────────────────────────────────────────────────────────── - 64 | - 65 | /** Returns the current theme: 'dark' | 'light'. */ - 66 | export async function getTheme(page: Page): Promise<'dark' | 'light'> { - 67 | const cls = await page.locator('html').getAttribute('class') ?? ''; - 68 | return cls.includes('dark') ? 'dark' : 'light'; - 69 | } - 70 | - 71 | /** Click the theme toggle and wait for the class to flip. */ - 72 | export async function toggleTheme(page: Page) { - 73 | const before = await getTheme(page); - 74 | // Theme toggle button — uses aria-label or data-testid set by the component - 75 | await page.getByRole('button', { name: /toggle theme|switch theme|dark mode|light mode/i }).click(); - 76 | await expect(page.locator('html')).toHaveClass(before === 'dark' ? /light/ : /dark/, { timeout: 2000 }); - 77 | } - 78 | - 79 | // ── API mock helpers ───────────────────────────────────────────────────────── - 80 | - 81 | /** Seed the `/api/stats` mock for deterministic dashboard tests. */ - 82 | export async function mockStats(page: Page, nodeCount = 1234, edgeCount = 5678) { - 83 | await page.route('**/api/stats', route => - 84 | route.fulfill({ - 85 | status: 200, - 86 | contentType: 'application/json', - 87 | body: JSON.stringify({ - 88 | totalNodes: nodeCount, - 89 | totalEdges: edgeCount, - 90 | nodesByKind: { endpoint: 10, class: 20, method: 30 }, - 91 | edgesByKind: { calls: 100, depends_on: 50 }, - 92 | languages: { java: 500, typescript: 200 }, - 93 | frameworks: { spring_boot: 300 }, - 94 | layers: { backend: 600, frontend: 200, infra: 100, shared: 50, unknown: 284 }, - 95 | }), - 96 | }) - 97 | ); - 98 | } - 99 | - 100 | /** - 101 | * Generate a synthetic node list for performance/stress tests. - 102 | * Returns a NodesListResponse-shaped object. - 103 | */ - 104 | export function generateNodeList(count: number) { - 105 | const nodes = Array.from({ length: count }, (_, i) => ({ - 106 | id: `node:file${i % 100}.ts:class:Class${i}`, - 107 | kind: ['class', 'method', 'endpoint', 'entity', 'function'][i % 5], - 108 | name: `Symbol${i}`, - 109 | qualifiedName: `com.example.Symbol${i}`, - 110 | filePath: `src/file${i % 100}.ts`, - 111 | layer: 'backend', - 112 | framework: null, - 113 | properties: {}, - 114 | })); - 115 | return { nodes, total: count, offset: 0, limit: count }; - 116 | } - 117 | - 118 | /** Seed the `/api/kinds` + `/api/nodes` endpoints with synthetic data. */ - 119 | export async function mockGraphData(page: Page, nodeCount: number) { - 120 | const data = generateNodeList(nodeCount); - 121 | - 122 | await page.route('**/api/kinds', route => - 123 | route.fulfill({ - 124 | status: 200, - 125 | contentType: 'application/json', - 126 | body: JSON.stringify({ - 127 | kinds: [ - 128 | { kind: 'class', count: Math.floor(nodeCount * 0.3) }, - 129 | { kind: 'method', count: Math.floor(nodeCount * 0.3) }, - 130 | { kind: 'endpoint', count: Math.floor(nodeCount * 0.15) }, - 131 | { kind: 'entity', count: Math.floor(nodeCount * 0.15) }, - 132 | { kind: 'function', count: Math.floor(nodeCount * 0.1) }, - 133 | ], - 134 | }), - 135 | }) - 136 | ); - 137 | - 138 | await page.route('**/api/nodes**', route => - 139 | route.fulfill({ - 140 | status: 200, - 141 | contentType: 'application/json', - 142 | body: JSON.stringify(data), - 143 | }) - 144 | ); - 145 | - 146 | await page.route('**/api/topology', route => - 147 | route.fulfill({ - 148 | status: 200, - 149 | contentType: 'application/json', - 150 | body: JSON.stringify({ - 151 | services: [ - 152 | { name: 'api-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['db-service'] }, - 153 | { name: 'db-service', nodeCount: Math.floor(nodeCount / 3), dependencies: [] }, - 154 | { name: 'frontend-service', nodeCount: Math.floor(nodeCount / 3), dependencies: ['api-service'] }, - 155 | ], - 156 | }), - 157 | }) - 158 | ); - 159 | } - 160 | -``` \ No newline at end of file diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/test-failed-1.png" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/test-failed-1.png" deleted file mode 100644 index 3ddab50b..00000000 Binary files "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/test-failed-1.png" and /dev/null differ diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/video.webm" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/video.webm" deleted file mode 100644 index 0a6f5b06..00000000 Binary files "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-graph-container-is-visible-chromium/video.webm" and /dev/null differ diff --git "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-minimap-is-visible-chromium/test-failed-1.png" "b/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-minimap-is-visible-chromium/test-failed-1.png" deleted file mode 100644 index 3ddab50b..00000000 Binary files "a/src/main/frontend/test-results/graph-Graph-view-\342\200\224-initial-render-minimap-is-visible-chromium/test-failed-1.png" and /dev/null differ diff --git a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java index 4647541b..824450db 100644 --- a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java +++ b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import io.github.randomcodespace.iq.intelligence.query.CapabilityMatrix; import io.github.randomcodespace.iq.model.NodeKind; import java.io.IOException; @@ -209,6 +210,24 @@ public Map getFileTree( return queryService.getFileTree(cappedDepth); } + @GetMapping("/capabilities") + public Map getCapabilities( + @RequestParam(required = false) String language) { + Map result = new java.util.LinkedHashMap<>(); + if (language != null && !language.isBlank()) { + result.put("language", language.strip().toLowerCase()); + result.put("capabilities", CapabilityMatrix.forLanguage(language).entrySet().stream() + .collect(java.util.stream.Collectors.toMap( + e -> e.getKey().name().toLowerCase(), + e -> e.getValue().name(), + (a, b) -> a, + java.util.TreeMap::new))); + } else { + result.put("matrix", CapabilityMatrix.asSerializableMap()); + } + return result; + } + private void validateNodeKind(String kind) { try { NodeKind.fromValue(kind); diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityDimension.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityDimension.java new file mode 100644 index 00000000..5455444d --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityDimension.java @@ -0,0 +1,26 @@ +package io.github.randomcodespace.iq.intelligence.query; + +/** + * Semantic dimensions of language intelligence used in the capability matrix. + * Each dimension corresponds to a distinct analysis concern. + */ +public enum CapabilityDimension { + /** Detection of symbol definitions (classes, functions, methods, variables). */ + SYMBOL_DEFINITIONS, + /** Detection of symbol references and usages across files. */ + SYMBOL_REFERENCES, + /** Resolution of import/require/use directives to target symbols. */ + IMPORT_RESOLUTION, + /** Type information extraction (static types, inferred types, generics). */ + TYPE_INFO, + /** Class hierarchy and interface/mixin relationship detection. */ + CLASS_HIERARCHY, + /** Framework-specific semantics (annotations, decorators, conventions). */ + FRAMEWORK_SEMANTICS, + /** ORM entity and relationship mapping detection. */ + ORM_ENTITY_MAPPING, + /** Authentication and authorization pattern detection. */ + AUTH_SECURITY, + /** Async, event-driven, and messaging pattern detection. */ + ASYNC_PATTERNS +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java new file mode 100644 index 00000000..dc005bd2 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java @@ -0,0 +1,285 @@ +package io.github.randomcodespace.iq.intelligence.query; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * Static, deterministic capability matrix that declares per-language analysis + * capability levels for each {@link CapabilityDimension}. + * + *

Levels reflect what the current detector suite actually provides: + *

    + *
  • Java — 27 detectors with JavaParser AST → {@code EXACT} for most dimensions.
  • + *
  • TypeScript / JavaScript — ANTLR grammar-based → {@code PARTIAL}.
  • + *
  • Python — ANTLR grammar-based → {@code PARTIAL}.
  • + *
  • Go — ANTLR grammar-based → {@code PARTIAL}.
  • + *
  • C# / Rust — ANTLR grammar-based → {@code PARTIAL}.
  • + *
  • Kotlin / Scala / Ruby / PHP / Shell / Markdown — regex only → {@code LEXICAL_ONLY}.
  • + *
  • Everything else — {@code UNSUPPORTED}.
  • + *
+ * + *

This class is intentionally non-instantiable. Use the static {@link #get} methods. + */ +public final class CapabilityMatrix { + + // ------------------------------------------------------------------ + // Language normalisation + // ------------------------------------------------------------------ + + /** Languages with ANTLR or JavaParser AST-level support. */ + private static final Set ANTLR_LANGUAGES = + Set.of("typescript", "javascript", "python", "go", "csharp", "rust", "cpp"); + + /** Languages with regex/text-only detection (no grammar). */ + private static final Set LEXICAL_ONLY_LANGUAGES = + Set.of("kotlin", "scala", "ruby", "php", "shell", "bash", "powershell", + "markdown", "proto", "hcl", "terraform", "dockerfile", "yaml", + "json", "toml", "ini", "properties", "xml", "sql"); + + // ------------------------------------------------------------------ + // Per-language dimension tables + // ------------------------------------------------------------------ + + /** Java: full AST analysis via JavaParser — highest fidelity. */ + private static final Map JAVA_CAPS; + + /** TypeScript: ANTLR grammar — good structural coverage. */ + private static final Map TYPESCRIPT_CAPS; + + /** JavaScript: same grammar as TypeScript, no static types. */ + private static final Map JAVASCRIPT_CAPS; + + /** Python: ANTLR grammar — class/function/import aware. */ + private static final Map PYTHON_CAPS; + + /** Go: ANTLR grammar — struct/interface/import aware. */ + private static final Map GO_CAPS; + + /** C#: ANTLR grammar — partial coverage. */ + private static final Map CSHARP_CAPS; + + /** Rust: ANTLR grammar — partial structural coverage. */ + private static final Map RUST_CAPS; + + /** C++: ANTLR grammar — partial structural coverage, no ORM convention. */ + private static final Map CPP_CAPS; + + /** Fallback for regex-only languages. */ + private static final Map LEXICAL_ONLY_CAPS; + + /** Fallback for completely unsupported languages. */ + private static final Map UNSUPPORTED_CAPS; + + static { + JAVA_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.EXACT, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.EXACT, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.EXACT, + CapabilityDimension.TYPE_INFO, CapabilityLevel.EXACT, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.EXACT, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.EXACT, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.EXACT, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.EXACT, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + TYPESCRIPT_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.PARTIAL, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.PARTIAL, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.PARTIAL, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.PARTIAL, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + JAVASCRIPT_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.PARTIAL, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.PARTIAL, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.PARTIAL, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + PYTHON_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.PARTIAL, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.PARTIAL, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.PARTIAL, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + GO_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.PARTIAL, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.PARTIAL, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + CSHARP_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.PARTIAL, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.PARTIAL, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.PARTIAL, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.PARTIAL, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + RUST_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.PARTIAL, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + CPP_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.PARTIAL, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.PARTIAL, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.PARTIAL, + CapabilityDimension.TYPE_INFO, CapabilityLevel.PARTIAL, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.PARTIAL, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.PARTIAL, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.PARTIAL + ); + + LEXICAL_ONLY_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.TYPE_INFO, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.LEXICAL_ONLY, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.LEXICAL_ONLY + ); + + UNSUPPORTED_CAPS = immutableDimMap( + CapabilityDimension.SYMBOL_DEFINITIONS, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.SYMBOL_REFERENCES, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.IMPORT_RESOLUTION, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.TYPE_INFO, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.CLASS_HIERARCHY, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.FRAMEWORK_SEMANTICS, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.ORM_ENTITY_MAPPING, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.AUTH_SECURITY, CapabilityLevel.UNSUPPORTED, + CapabilityDimension.ASYNC_PATTERNS, CapabilityLevel.UNSUPPORTED + ); + } + + private CapabilityMatrix() {} + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + /** + * Returns the full dimension-to-level map for {@code language}. + * The map is sorted by {@link CapabilityDimension} ordinal order (deterministic). + * + * @param language normalised lowercase language name + * @return immutable capability map; never {@code null} + */ + public static Map forLanguage(String language) { + return tableFor(normalise(language)); + } + + /** + * Returns the capability level for a single {@code language} / {@code dimension} pair. + * + * @param language normalised lowercase language name + * @param dimension the capability dimension to query + * @return the capability level; never {@code null} + */ + public static CapabilityLevel get(String language, CapabilityDimension dimension) { + return tableFor(normalise(language)).getOrDefault(dimension, CapabilityLevel.UNSUPPORTED); + } + + /** + * Returns the full matrix as a serialisation-friendly nested map. + * Outer keys are language names (sorted), inner keys are dimension names, values are level names. + * Deterministic by construction. + */ + public static Map> asSerializableMap() { + Map> result = new TreeMap<>(); + for (String lang : new String[]{ + "java", "typescript", "javascript", "python", "go", "csharp", "rust", "cpp", + "kotlin", "scala", "ruby", "php", "shell"}) { + Map caps = tableFor(lang); + Map row = new LinkedHashMap<>(); + for (CapabilityDimension dim : CapabilityDimension.values()) { + row.put(dim.name().toLowerCase(), caps.getOrDefault(dim, CapabilityLevel.UNSUPPORTED).name()); + } + result.put(lang, row); + } + return Collections.unmodifiableMap(result); + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + private static String normalise(String language) { + if (language == null) return ""; + return language.strip().toLowerCase(); + } + + private static Map tableFor(String lang) { + return switch (lang) { + case "java" -> JAVA_CAPS; + case "typescript" -> TYPESCRIPT_CAPS; + case "javascript" -> JAVASCRIPT_CAPS; + case "python" -> PYTHON_CAPS; + case "go" -> GO_CAPS; + case "csharp", "c#" -> CSHARP_CAPS; + case "rust" -> RUST_CAPS; + case "cpp", "c++" -> CPP_CAPS; + default -> { + if (LEXICAL_ONLY_LANGUAGES.contains(lang)) yield LEXICAL_ONLY_CAPS; + yield UNSUPPORTED_CAPS; + } + }; + } + + /** + * Varargs helper: alternating {@code (dimension, level)} pairs → immutable EnumMap. + */ + private static Map immutableDimMap(Object... pairs) { + EnumMap map = new EnumMap<>(CapabilityDimension.class); + for (int i = 0; i < pairs.length - 1; i += 2) { + map.put((CapabilityDimension) pairs[i], (CapabilityLevel) pairs[i + 1]); + } + return Collections.unmodifiableMap(map); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlan.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlan.java new file mode 100644 index 00000000..4ac5384d --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlan.java @@ -0,0 +1,43 @@ +package io.github.randomcodespace.iq.intelligence.query; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; + +import java.util.Map; + +/** + * Immutable result produced by the {@link QueryPlanner}. + * Describes how a given query intent should be executed for a specific language. + * + * @param queryType The type of query being planned. + * @param language Normalised lowercase language name (e.g. {@code "java"}, {@code "python"}). + * @param route The selected retrieval path. + * @param capabilities Snapshot of the capability levels for dimensions relevant to this query. + * @param degradationNote Human-readable explanation when {@code route} is + * {@link QueryRoute#LEXICAL_FIRST} or {@link QueryRoute#DEGRADED}; + * {@code null} for {@link QueryRoute#GRAPH_FIRST} and {@link QueryRoute#MERGED}. + */ +public record QueryPlan( + QueryType queryType, + String language, + QueryRoute route, + Map capabilities, + String degradationNote +) { + /** + * Convenience factory for a fully-capable plan (no degradation note). + */ + public static QueryPlan of(QueryType queryType, String language, QueryRoute route, + Map capabilities) { + return new QueryPlan(queryType, language, route, capabilities, null); + } + + /** Returns {@code true} if this plan involves any graph traversal. */ + public boolean usesGraph() { + return route == QueryRoute.GRAPH_FIRST || route == QueryRoute.MERGED; + } + + /** Returns {@code true} if this plan involves lexical/text search. */ + public boolean usesLexical() { + return route == QueryRoute.LEXICAL_FIRST || route == QueryRoute.MERGED; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java new file mode 100644 index 00000000..df636b8b --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java @@ -0,0 +1,138 @@ +package io.github.randomcodespace.iq.intelligence.query; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; + +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Deterministic query planner that routes a query intent to the correct retrieval path + * based on explicit rules derived from the {@link CapabilityMatrix}. + * + *

Routing rules (no LLM, no probabilistic logic): + *

    + *
  1. {@link QueryRoute#GRAPH_FIRST} — all relevant dimensions are {@code EXACT}.
  2. + *
  3. {@link QueryRoute#MERGED} — at least one relevant dimension is {@code PARTIAL} + * (graph results plus lexical search for coverage).
  4. + *
  5. {@link QueryRoute#LEXICAL_FIRST} — all relevant dimensions are {@code LEXICAL_ONLY}.
  6. + *
  7. {@link QueryRoute#DEGRADED} — any relevant dimension is {@code UNSUPPORTED}.
  8. + *
+ * + *

{@link QueryType#SEARCH_TEXT} always routes to {@link QueryRoute#LEXICAL_FIRST} + * regardless of language, because text search operates on raw source content, not the graph. + */ +public class QueryPlanner { + + // ------------------------------------------------------------------ + // Query-type → relevant dimensions mapping (deterministic, static) + // ------------------------------------------------------------------ + + private static final Map> QUERY_DIMENSIONS = Map.of( + QueryType.FIND_SYMBOL, List.of(CapabilityDimension.SYMBOL_DEFINITIONS), + QueryType.FIND_REFERENCES, List.of(CapabilityDimension.SYMBOL_REFERENCES), + QueryType.FIND_CALLERS, List.of(CapabilityDimension.SYMBOL_REFERENCES), + QueryType.FIND_DEPENDENCIES, List.of(CapabilityDimension.IMPORT_RESOLUTION), + QueryType.SEARCH_TEXT, List.of(), // special-cased below + QueryType.FIND_CONFIG, List.of(CapabilityDimension.FRAMEWORK_SEMANTICS) + ); + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + /** + * Produce a {@link QueryPlan} for the given {@code queryType} and {@code language}. + * The result is fully deterministic for the same input. + * + * @param queryType the type of query being planned + * @param language normalised lowercase language name (e.g. {@code "java"}, {@code "python"}) + * @return a non-null {@link QueryPlan} + */ + public QueryPlan plan(QueryType queryType, String language) { + Map caps = CapabilityMatrix.forLanguage(language); + + // SEARCH_TEXT is always lexical — the graph does not index raw text content + if (queryType == QueryType.SEARCH_TEXT) { + return new QueryPlan(queryType, language, QueryRoute.LEXICAL_FIRST, caps, null); + } + + List relevant = QUERY_DIMENSIONS.getOrDefault(queryType, List.of()); + + if (relevant.isEmpty()) { + // Unknown query type with no dimension mapping → treat as degraded + return new QueryPlan(queryType, language, QueryRoute.DEGRADED, caps, + "No capability dimensions are mapped for query type " + queryType + + ". This query type may not be supported yet."); + } + + Set levels = EnumSet.noneOf(CapabilityLevel.class); + for (CapabilityDimension dim : relevant) { + levels.add(caps.getOrDefault(dim, CapabilityLevel.UNSUPPORTED)); + } + + QueryRoute route = selectRoute(levels, queryType, language); + String degradationNote = buildDegradationNote(route, levels, queryType, language, relevant); + + return new QueryPlan(queryType, language, route, caps, degradationNote); + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + /** + * Select the route given the set of capability levels for the relevant dimensions. + * Priority: DEGRADED > LEXICAL_FIRST > MERGED > GRAPH_FIRST. + */ + private QueryRoute selectRoute(Set levels, + QueryType queryType, String language) { + if (levels.contains(CapabilityLevel.UNSUPPORTED)) { + return QueryRoute.DEGRADED; + } + if (levels.contains(CapabilityLevel.LEXICAL_ONLY) && levels.contains(CapabilityLevel.EXACT)) { + // Some dimensions exact, others lexical-only → merge for best coverage + return QueryRoute.MERGED; + } + if (levels.contains(CapabilityLevel.PARTIAL)) { + return QueryRoute.MERGED; + } + if (levels.contains(CapabilityLevel.LEXICAL_ONLY)) { + return QueryRoute.LEXICAL_FIRST; + } + // All dimensions are EXACT + return QueryRoute.GRAPH_FIRST; + } + + /** + * Build a human-readable degradation note for LEXICAL_FIRST and DEGRADED routes. + * Returns {@code null} for GRAPH_FIRST and MERGED (no explanation needed). + */ + private String buildDegradationNote(QueryRoute route, + Set levels, + QueryType queryType, + String language, + List relevant) { + if (route == QueryRoute.GRAPH_FIRST) return null; + if (route == QueryRoute.MERGED) return null; + + String lang = language == null || language.isBlank() ? "this language" : "'" + language + "'"; + String dims = relevant.stream() + .map(d -> d.name().toLowerCase().replace('_', ' ')) + .reduce((a, b) -> a + ", " + b) + .orElse("the requested dimensions"); + + if (route == QueryRoute.DEGRADED) { + return "Query type " + queryType + " is not supported for " + lang + ". " + + "The current extractor suite has no structural analysis for " + dims + ". " + + "Consider running the analysis on a supported language (java, typescript, " + + "javascript, python, go, csharp, rust) or use SEARCH_TEXT for lexical fallback."; + } + + // LEXICAL_FIRST + return "Query type " + queryType + " for " + lang + " uses lexical search only. " + + "Structural graph analysis is unavailable for " + dims + " in " + lang + ". " + + "Results may be less precise than for fully-supported languages."; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryRoute.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryRoute.java new file mode 100644 index 00000000..773693f9 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryRoute.java @@ -0,0 +1,28 @@ +package io.github.randomcodespace.iq.intelligence.query; + +/** + * The retrieval path chosen by the {@link QueryPlanner} for a given query intent and language. + */ +public enum QueryRoute { + /** + * Primary path: query the structural graph (Neo4j). + * Used when capability is {@code EXACT} — AST-level analysis is available. + */ + GRAPH_FIRST, + /** + * Fallback path: lexical/text search only. + * Used when capability is {@code LEXICAL_ONLY} — no structural analysis is available. + */ + LEXICAL_FIRST, + /** + * Combined path: graph results augmented with lexical search. + * Used when capability is {@code PARTIAL} — structural analysis is incomplete + * and lexical search fills the gaps. + */ + MERGED, + /** + * Degraded path: the feature is unsupported for this language. + * A {@code degradationNote} is included in the {@link QueryPlan} to explain what is missing. + */ + DEGRADED +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryType.java b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryType.java new file mode 100644 index 00000000..1416dc54 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryType.java @@ -0,0 +1,20 @@ +package io.github.randomcodespace.iq.intelligence.query; + +/** + * Enumeration of query intents the query planner can route. + * Each type maps to one or more {@link CapabilityDimension}s for routing decisions. + */ +public enum QueryType { + /** Locate symbol definitions (classes, functions, methods, variables). */ + FIND_SYMBOL, + /** Find all usages/references of a symbol across the codebase. */ + FIND_REFERENCES, + /** Find callers of a function or method. */ + FIND_CALLERS, + /** Find modules or packages that a given module depends on. */ + FIND_DEPENDENCIES, + /** Full-text / lexical search across source files. */ + SEARCH_TEXT, + /** Locate configuration files and structured config values. */ + FIND_CONFIG +} diff --git a/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java b/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java index 9bfb4388..1dbc285a 100644 --- a/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java +++ b/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.randomcodespace.iq.config.CodeIqConfig; import io.github.randomcodespace.iq.flow.FlowEngine; +import io.github.randomcodespace.iq.intelligence.query.CapabilityMatrix; // Note: No Analyzer import — MCP server is read-only. Analysis is done via CLI only. import io.github.randomcodespace.iq.flow.FlowModels.FlowDiagram; import io.github.randomcodespace.iq.graph.GraphStore; @@ -337,6 +338,26 @@ public String searchGraph( } } + @McpTool(name = "get_capabilities", description = "Return the capability matrix declaring per-language analysis fidelity levels (EXACT/PARTIAL/LEXICAL_ONLY/UNSUPPORTED) for each intelligence dimension. Optionally filter by a single language.") + public String getCapabilities( + @McpToolParam(description = "Language to filter (e.g. java, python). Omit for the full matrix.", required = false) String language) { + try { + Map result = new LinkedHashMap<>(); + if (language != null && !language.isBlank()) { + result.put("language", language.strip().toLowerCase()); + Map caps = new java.util.TreeMap<>(); + CapabilityMatrix.forLanguage(language) + .forEach((dim, lvl) -> caps.put(dim.name().toLowerCase(), lvl.name())); + result.put("capabilities", caps); + } else { + result.put("matrix", CapabilityMatrix.asSerializableMap()); + } + return toJson(result); + } catch (Exception e) { + return toJson(Map.of("error", e.getMessage())); + } + } + @McpTool(name = "read_file", description = "Read a source file from the codebase, optionally a specific line range") public String readFile( @McpToolParam(description = "File path relative to codebase root") String filePath, diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrixTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrixTest.java new file mode 100644 index 00000000..74051e76 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrixTest.java @@ -0,0 +1,167 @@ +package io.github.randomcodespace.iq.intelligence.query; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link CapabilityMatrix}. + * Validates per-language capability lookups and matrix completeness. + */ +class CapabilityMatrixTest { + + // ------------------------------------------------------------------ + // Java — highest fidelity + // ------------------------------------------------------------------ + + @Test + void java_symbolDefinitions_isExact() { + assertThat(CapabilityMatrix.get("java", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.EXACT); + } + + @Test + void java_frameworkSemantics_isExact() { + assertThat(CapabilityMatrix.get("java", CapabilityDimension.FRAMEWORK_SEMANTICS)) + .isEqualTo(CapabilityLevel.EXACT); + } + + @Test + void java_ormEntityMapping_isExact() { + assertThat(CapabilityMatrix.get("java", CapabilityDimension.ORM_ENTITY_MAPPING)) + .isEqualTo(CapabilityLevel.EXACT); + } + + @Test + void java_allDimensionsPopulated() { + Map caps = CapabilityMatrix.forLanguage("java"); + assertThat(caps).containsKeys(CapabilityDimension.values()); + } + + // ------------------------------------------------------------------ + // TypeScript — PARTIAL across most dimensions + // ------------------------------------------------------------------ + + @Test + void typescript_symbolDefinitions_isPartial() { + assertThat(CapabilityMatrix.get("typescript", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.PARTIAL); + } + + @Test + void typescript_allDimensionsPopulated() { + Map caps = CapabilityMatrix.forLanguage("typescript"); + assertThat(caps).containsKeys(CapabilityDimension.values()); + } + + // ------------------------------------------------------------------ + // Python — PARTIAL structural, LEXICAL_ONLY for type info + // ------------------------------------------------------------------ + + @Test + void python_symbolDefinitions_isPartial() { + assertThat(CapabilityMatrix.get("python", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.PARTIAL); + } + + @Test + void python_typeInfo_isLexicalOnly() { + assertThat(CapabilityMatrix.get("python", CapabilityDimension.TYPE_INFO)) + .isEqualTo(CapabilityLevel.LEXICAL_ONLY); + } + + // ------------------------------------------------------------------ + // Lexical-only languages + // ------------------------------------------------------------------ + + @Test + void kotlin_symbolDefinitions_isLexicalOnly() { + assertThat(CapabilityMatrix.get("kotlin", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.LEXICAL_ONLY); + } + + @Test + void kotlin_typeInfo_isUnsupported() { + assertThat(CapabilityMatrix.get("kotlin", CapabilityDimension.TYPE_INFO)) + .isEqualTo(CapabilityLevel.UNSUPPORTED); + } + + @Test + void shell_ormEntityMapping_isUnsupported() { + assertThat(CapabilityMatrix.get("shell", CapabilityDimension.ORM_ENTITY_MAPPING)) + .isEqualTo(CapabilityLevel.UNSUPPORTED); + } + + // ------------------------------------------------------------------ + // Unknown language → UNSUPPORTED + // ------------------------------------------------------------------ + + @Test + void unknownLanguage_allUnsupported() { + Map caps = CapabilityMatrix.forLanguage("brainfuck"); + assertThat(caps.values()).allMatch(l -> l == CapabilityLevel.UNSUPPORTED); + } + + @Test + void nullLanguage_allUnsupported() { + Map caps = CapabilityMatrix.forLanguage(null); + assertThat(caps.values()).allMatch(l -> l == CapabilityLevel.UNSUPPORTED); + } + + @Test + void caseNormalisation_java_upper() { + assertThat(CapabilityMatrix.get("JAVA", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.EXACT); + } + + @Test + void caseNormalisation_java_mixed() { + assertThat(CapabilityMatrix.get("Java", CapabilityDimension.FRAMEWORK_SEMANTICS)) + .isEqualTo(CapabilityLevel.EXACT); + } + + // ------------------------------------------------------------------ + // Rust — ORM is UNSUPPORTED + // ------------------------------------------------------------------ + + @Test + void rust_ormEntityMapping_isUnsupported() { + assertThat(CapabilityMatrix.get("rust", CapabilityDimension.ORM_ENTITY_MAPPING)) + .isEqualTo(CapabilityLevel.UNSUPPORTED); + } + + @Test + void rust_symbolDefinitions_isPartial() { + assertThat(CapabilityMatrix.get("rust", CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.PARTIAL); + } + + // ------------------------------------------------------------------ + // asSerializableMap — determinism + // ------------------------------------------------------------------ + + @Test + void serializableMap_isDeterministic() { + Map> first = CapabilityMatrix.asSerializableMap(); + Map> second = CapabilityMatrix.asSerializableMap(); + assertThat(first).isEqualTo(second); + } + + @Test + void serializableMap_containsExpectedLanguages() { + Map> matrix = CapabilityMatrix.asSerializableMap(); + assertThat(matrix).containsKeys("java", "typescript", "javascript", "python", "go", "csharp", "rust"); + } + + @Test + void serializableMap_allDimensionsCovered() { + Map> matrix = CapabilityMatrix.asSerializableMap(); + for (Map.Entry> entry : matrix.entrySet()) { + assertThat(entry.getValue()).as("language=%s", entry.getKey()) + .hasSize(CapabilityDimension.values().length); + } + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/query/QueryPlannerTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/query/QueryPlannerTest.java new file mode 100644 index 00000000..a6ae9119 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/query/QueryPlannerTest.java @@ -0,0 +1,212 @@ +package io.github.randomcodespace.iq.intelligence.query; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit and integration tests for {@link QueryPlanner}. + * Covers each routing path and the determinism contract. + */ +class QueryPlannerTest { + + private QueryPlanner planner; + + @BeforeEach + void setUp() { + planner = new QueryPlanner(); + } + + // ------------------------------------------------------------------ + // GRAPH_FIRST path — Java has EXACT capability + // ------------------------------------------------------------------ + + @Test + void java_findSymbol_routesGraphFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "java"); + assertThat(plan.route()).isEqualTo(QueryRoute.GRAPH_FIRST); + assertThat(plan.usesGraph()).isTrue(); + assertThat(plan.usesLexical()).isFalse(); + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void java_findCallers_routesGraphFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_CALLERS, "java"); + assertThat(plan.route()).isEqualTo(QueryRoute.GRAPH_FIRST); + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void java_findDependencies_routesGraphFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_DEPENDENCIES, "java"); + assertThat(plan.route()).isEqualTo(QueryRoute.GRAPH_FIRST); + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void java_findConfig_routesGraphFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_CONFIG, "java"); + assertThat(plan.route()).isEqualTo(QueryRoute.GRAPH_FIRST); + assertThat(plan.degradationNote()).isNull(); + } + + // ------------------------------------------------------------------ + // MERGED path — TypeScript/Python have PARTIAL capability + // ------------------------------------------------------------------ + + @Test + void typescript_findSymbol_routesMerged() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "typescript"); + assertThat(plan.route()).isEqualTo(QueryRoute.MERGED); + assertThat(plan.usesGraph()).isTrue(); + assertThat(plan.usesLexical()).isTrue(); + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void python_findCallers_routesMerged() { + QueryPlan plan = planner.plan(QueryType.FIND_CALLERS, "python"); + assertThat(plan.route()).isEqualTo(QueryRoute.MERGED); + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void go_findDependencies_routesMerged() { + QueryPlan plan = planner.plan(QueryType.FIND_DEPENDENCIES, "go"); + assertThat(plan.route()).isEqualTo(QueryRoute.MERGED); + assertThat(plan.degradationNote()).isNull(); + } + + // ------------------------------------------------------------------ + // LEXICAL_FIRST path — lexical-only languages + // ------------------------------------------------------------------ + + @Test + void kotlin_findSymbol_routesLexicalFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "kotlin"); + assertThat(plan.route()).isEqualTo(QueryRoute.LEXICAL_FIRST); + assertThat(plan.usesGraph()).isFalse(); + assertThat(plan.usesLexical()).isTrue(); + assertThat(plan.degradationNote()).isNotBlank(); + } + + @Test + void shell_findCallers_routesLexicalFirst() { + QueryPlan plan = planner.plan(QueryType.FIND_CALLERS, "shell"); + assertThat(plan.route()).isEqualTo(QueryRoute.LEXICAL_FIRST); + assertThat(plan.degradationNote()).isNotBlank(); + } + + // ------------------------------------------------------------------ + // DEGRADED path — unsupported language or dimension + // ------------------------------------------------------------------ + + @Test + void unknownLanguage_findSymbol_routesDegraded() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "brainfuck"); + assertThat(plan.route()).isEqualTo(QueryRoute.DEGRADED); + assertThat(plan.usesGraph()).isFalse(); + assertThat(plan.usesLexical()).isFalse(); + assertThat(plan.degradationNote()).isNotBlank(); + } + + @Test + void rust_findSymbol_routesMerged_notDegraded() { + // Rust SYMBOL_DEFINITIONS is PARTIAL — should be MERGED, not DEGRADED + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "rust"); + assertThat(plan.route()).isEqualTo(QueryRoute.MERGED); + } + + @Test + void degradedPlan_hasExplanatoryNote() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "brainfuck"); + assertThat(plan.degradationNote()) + .contains("FIND_SYMBOL") + .contains("brainfuck"); + } + + // ------------------------------------------------------------------ + // LEXICAL_FIRST always — SEARCH_TEXT + // ------------------------------------------------------------------ + + @Test + void java_searchText_routesLexicalFirst() { + QueryPlan plan = planner.plan(QueryType.SEARCH_TEXT, "java"); + assertThat(plan.route()).isEqualTo(QueryRoute.LEXICAL_FIRST); + assertThat(plan.usesLexical()).isTrue(); + // No degradation note — SEARCH_TEXT lexical routing is normal behaviour + assertThat(plan.degradationNote()).isNull(); + } + + @Test + void unknownLanguage_searchText_routesLexicalFirst() { + QueryPlan plan = planner.plan(QueryType.SEARCH_TEXT, "brainfuck"); + assertThat(plan.route()).isEqualTo(QueryRoute.LEXICAL_FIRST); + } + + // ------------------------------------------------------------------ + // Query plan fields + // ------------------------------------------------------------------ + + @Test + void plan_populatesQueryTypeAndLanguage() { + QueryPlan plan = planner.plan(QueryType.FIND_REFERENCES, "java"); + assertThat(plan.queryType()).isEqualTo(QueryType.FIND_REFERENCES); + assertThat(plan.language()).isEqualTo("java"); + } + + @Test + void plan_capabilitiesContainAllDimensions() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "java"); + assertThat(plan.capabilities()).containsKeys(CapabilityDimension.values()); + } + + @Test + void plan_capabilitiesMatchMatrix() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "java"); + assertThat(plan.capabilities().get(CapabilityDimension.SYMBOL_DEFINITIONS)) + .isEqualTo(CapabilityLevel.EXACT); + } + + // ------------------------------------------------------------------ + // Determinism test — same input always produces same output + // ------------------------------------------------------------------ + + @Test + void determinism_sameInputProducesSameOutput() { + for (QueryType qt : QueryType.values()) { + for (String lang : new String[]{"java", "typescript", "python", "go", "kotlin", "brainfuck"}) { + QueryPlan first = planner.plan(qt, lang); + QueryPlan second = planner.plan(qt, lang); + assertThat(first.route()) + .as("route for %s/%s must be deterministic", qt, lang) + .isEqualTo(second.route()); + assertThat(first.degradationNote()) + .as("degradationNote for %s/%s must be deterministic", qt, lang) + .isEqualTo(second.degradationNote()); + assertThat(first.capabilities()) + .as("capabilities for %s/%s must be deterministic", qt, lang) + .isEqualTo(second.capabilities()); + } + } + } + + // ------------------------------------------------------------------ + // Degradation note quality + // ------------------------------------------------------------------ + + @Test + void lexicalOnlyNote_mentionsLanguage() { + QueryPlan plan = planner.plan(QueryType.FIND_SYMBOL, "kotlin"); + assertThat(plan.degradationNote()).contains("kotlin"); + } + + @Test + void lexicalOnlyNote_mentionsQueryType() { + QueryPlan plan = planner.plan(QueryType.FIND_REFERENCES, "kotlin"); + assertThat(plan.degradationNote()).containsIgnoringCase("FIND_REFERENCES"); + } +}