diff --git a/.gitignore b/.gitignore index ed53e658..ad3520db 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,8 @@ src/main/frontend/node_modules/ src/main/frontend/node/ src/main/frontend/dist/ src/main/frontend/tsconfig.tsbuildinfo +playwright-report/ +test-results/ # Generated explorer CSS (rebuild via: cd src/main/frontend && npm run build:explorer-css) src/main/resources/static/css/explorer.css 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/analyzer/Analyzer.java b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java index 21b84c0c..93037261 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java @@ -3,6 +3,7 @@ import io.github.randomcodespace.iq.analyzer.linker.Linker; import io.github.randomcodespace.iq.cache.AnalysisCache; import io.github.randomcodespace.iq.cache.FileHasher; +import io.github.randomcodespace.iq.cli.VersionCommand; import io.github.randomcodespace.iq.config.CodeIqConfig; import io.github.randomcodespace.iq.config.ProjectConfig; import io.github.randomcodespace.iq.config.ProjectConfigLoader; @@ -12,6 +13,10 @@ import io.github.randomcodespace.iq.detector.DetectorResult; import io.github.randomcodespace.iq.detector.DetectorUtils; import io.github.randomcodespace.iq.grammar.AntlrParserFactory; +import io.github.randomcodespace.iq.intelligence.FileClassification; +import io.github.randomcodespace.iq.intelligence.FileEntry; +import io.github.randomcodespace.iq.intelligence.FileInventory; +import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; import io.github.randomcodespace.iq.model.NodeKind; @@ -227,6 +232,10 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach int totalFiles = files.size(); report.accept("Found " + totalFiles + " files"); + // 1b. Resolve repository identity and build file inventory + RepositoryIdentity repoIdentity = RepositoryIdentity.resolve(root); + FileInventory fileInventory = buildFileInventory(files, cache); + // Compute language breakdown Map languageBreakdown = new HashMap<>(); for (DiscoveredFile f : files) { @@ -300,7 +309,7 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach // 3. Build graph (batched) report.accept("Building graph..."); - var builder = new GraphBuilder(); + var builder = new GraphBuilder(repoIdentity, VersionCommand.VERSION); int filesAnalyzed = 0; for (int i = 0; i < resultSlots.length; i++) { DetectorResult result = resultSlots[i]; @@ -329,6 +338,7 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach String projectDirName = root.getFileName() != null ? root.getFileName().toString() : "root"; var serviceResult = serviceDetector.detect(allNodes, builder.getEdges(), projectDirName, root); if (!serviceResult.serviceNodes().isEmpty()) { + serviceResult.serviceNodes().forEach(n -> n.setProvenance(builder.getProvenance())); builder.addNodes(serviceResult.serviceNodes()); builder.addEdges(serviceResult.serviceEdges()); allNodes = builder.getNodes(); // refresh reference after adding service nodes @@ -1257,6 +1267,22 @@ DetectorResult analyzeFile(DiscoveredFile file, Path repoPath, DetectorRegistry return DetectorResult.of(allNodes, allEdges); } + /** + * Build a deterministic FileInventory from the list of discovered files. + * Content hashes are reused from {@code cache} when available (no re-read of files). + * Hashes remain null for files not yet present in the cache. + */ + private static FileInventory buildFileInventory(List files, AnalysisCache cache) { + List entries = new ArrayList<>(files.size()); + for (DiscoveredFile f : files) { + String relPath = f.path().toString().replace('\\', '/'); + FileClassification cls = FileEntry.classify(relPath, f.language()); + String contentHash = cache != null ? cache.getHashForPath(relPath) : null; + entries.add(new FileEntry(relPath, f.language(), f.sizeBytes(), contentHash, cls)); + } + return new FileInventory(entries); + } + /** * Get the current git HEAD commit SHA, or null if not a git repo. */ diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java b/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java index cd357831..a81632f8 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/GraphBuilder.java @@ -3,6 +3,9 @@ import io.github.randomcodespace.iq.analyzer.linker.LinkResult; import io.github.randomcodespace.iq.analyzer.linker.Linker; import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; import org.slf4j.Logger; @@ -34,13 +37,46 @@ public class GraphBuilder { private final List deferredEdges = new ArrayList<>(); private int droppedEdgeCount = 0; private final int batchSize; + private final Provenance provenance; public GraphBuilder() { - this(1000); + this(1000, null); } public GraphBuilder(int batchSize) { + this(batchSize, null); + } + + /** + * Construct with repository identity and extractor version. + * Provenance is derived internally from the identity. + */ + public GraphBuilder(RepositoryIdentity identity, String extractorVersion) { + this(1000, identity, extractorVersion); + } + + /** + * Construct with batch size, repository identity, and extractor version. + * Provenance is derived internally from the identity. + */ + public GraphBuilder(int batchSize, RepositoryIdentity identity, String extractorVersion) { + this(batchSize, identity == null ? null : new Provenance( + identity.repoUrl(), + identity.commitSha(), + extractorVersion, + Provenance.CURRENT_SCHEMA_VERSION, + CapabilityLevel.PARTIAL + )); + } + + private GraphBuilder(int batchSize, Provenance provenance) { this.batchSize = Math.max(1, batchSize); + this.provenance = provenance; + } + + /** Returns the provenance stamped on every node, or null if none was configured. */ + public Provenance getProvenance() { + return provenance; } /** @@ -67,11 +103,18 @@ public void addEdges(List edges) { /** * Flush all buffered data: insert nodes first, then edges. - * Edges whose source or target node doesn't exist are deferred. + * Applies provenance to every node, then partitions edges into valid/deferred. * * @return a snapshot of all valid nodes and edges */ public FlushResult flush() { + // Stamp provenance on every node + if (provenance != null) { + for (CodeNode node : allNodes) { + node.setProvenance(provenance); + } + } + // Build the set of all node IDs Set nodeIds = new HashSet<>(); for (CodeNode node : allNodes) { 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/api/IntelligenceController.java b/src/main/java/io/github/randomcodespace/iq/api/IntelligenceController.java new file mode 100644 index 00000000..1a607115 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/api/IntelligenceController.java @@ -0,0 +1,127 @@ +package io.github.randomcodespace.iq.api; + +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePack; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackAssembler; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackRequest; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import io.github.randomcodespace.iq.intelligence.query.CapabilityMatrix; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Map; + +/** + * Intelligence REST API — evidence packs, artifact metadata, and capability matrix. + * Read-only. Active only in the {@code serving} profile. + */ +@RestController +@RequestMapping("/api/intelligence") +@Profile("serving") +public class IntelligenceController { + + private final EvidencePackAssembler assembler; + private final ArtifactMetadata artifactMetadata; + private final CodeIqConfig config; + + public IntelligenceController( + @org.springframework.beans.factory.annotation.Autowired(required = false) + EvidencePackAssembler assembler, + @org.springframework.beans.factory.annotation.Autowired(required = false) + ArtifactMetadata artifactMetadata, + CodeIqConfig config) { + this.assembler = assembler; + this.artifactMetadata = artifactMetadata; + this.config = config; + } + + /** + * Assemble an evidence pack for a symbol or file path. + * + *

At least one of {@code symbol} or {@code file} must be provided. + * The {@code file} parameter is path-traversal guarded. + * + * @param symbol symbol name to look up + * @param file file path relative to repo root (path traversal guarded) + * @param maxSnippetLines max lines per snippet (optional, capped at config limit) + * @param includeRefs whether to include cross-reference nodes (default false) + * @return assembled evidence pack + */ + @GetMapping("/evidence") + public EvidencePack getEvidence( + @RequestParam(required = false) String symbol, + @RequestParam(required = false) String file, + @RequestParam(required = false) Integer maxSnippetLines, + @RequestParam(defaultValue = "false") boolean includeRefs) { + + requireAssembler(); + + // 400 when both are absent + if ((symbol == null || symbol.isBlank()) && (file == null || file.isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "At least one of 'symbol' or 'file' must be provided."); + } + + // Path traversal guard on file param + if (file != null && !file.isBlank()) { + java.nio.file.Path root = java.nio.file.Path.of(config.getRootPath()) + .toAbsolutePath().normalize(); + java.nio.file.Path resolved = root.resolve(file).normalize(); + if (!resolved.startsWith(root)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Invalid file path: path traversal detected."); + } + } + + EvidencePackRequest request = new EvidencePackRequest(symbol, file, maxSnippetLines, includeRefs); + return assembler.assemble(request, artifactMetadata); + } + + /** + * Returns the artifact metadata loaded at serve startup. + */ + @GetMapping("/manifest") + public ArtifactMetadata getManifest() { + if (artifactMetadata == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Artifact metadata unavailable. Run 'enrich' first."); + } + return artifactMetadata; + } + + /** + * Returns the full capability matrix as a JSON object. + * Optionally filter by language. + * + * @param language optional language filter (e.g. "java", "python") + */ + @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 requireAssembler() { + if (assembler == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Intelligence service unavailable. Run 'enrich' first."); + } + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/cache/AnalysisCache.java b/src/main/java/io/github/randomcodespace/iq/cache/AnalysisCache.java index b953a661..7590ea04 100644 --- a/src/main/java/io/github/randomcodespace/iq/cache/AnalysisCache.java +++ b/src/main/java/io/github/randomcodespace/iq/cache/AnalysisCache.java @@ -153,6 +153,23 @@ public synchronized boolean isCached(String contentHash) { } } + /** + * Look up the content hash stored for a given file path. + * Returns null if the path has not been cached yet. + */ + public synchronized String getHashForPath(String filePath) { + try (var stmt = conn.prepareStatement( + "SELECT content_hash FROM files WHERE path = ? LIMIT 1")) { + stmt.setString(1, filePath); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next() ? rs.getString(1) : null; + } + } catch (SQLException e) { + log.debug("Hash lookup by path failed", e); + return null; + } + } + // --- Store results --- /** diff --git a/src/main/java/io/github/randomcodespace/iq/cli/BundleCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/BundleCommand.java index 7ff5e3d0..4cabc4fa 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/BundleCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/BundleCommand.java @@ -6,6 +6,9 @@ import io.github.randomcodespace.iq.config.CodeIqConfig; import io.github.randomcodespace.iq.flow.FlowEngine; import io.github.randomcodespace.iq.graph.GraphStore; +import io.github.randomcodespace.iq.intelligence.ArtifactManifest; +import io.github.randomcodespace.iq.intelligence.FileInventory; +import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import picocli.CommandLine.Command; @@ -120,7 +123,6 @@ public Integer call() { // Get node/edge counts from H2 cache long nodeCount = 0, edgeCount = 0; - int filesAnalyzed = 0; if (Files.isDirectory(h2Dir)) { try (var cache = new AnalysisCache(h2Dir.resolve("analysis-cache.db"))) { nodeCount = cache.getNodeCount(); @@ -136,8 +138,9 @@ public Integer call() { // 1. manifest.json CliOutput.info(" Writing manifest.json"); - String manifest = createManifest(projectName, bundleTag, version, - nodeCount, edgeCount, filesAnalyzed, !noSource, includeJar); + RepositoryIdentity repoIdentity = RepositoryIdentity.resolve(root); + String manifest = createManifest(projectName, bundleTag, version, repoIdentity, + nodeCount, edgeCount, !noSource, includeJar); writeEntry(zos, "manifest.json", manifest); // 2. serve.sh @@ -217,27 +220,27 @@ public Integer call() { // --- Manifest --- private String createManifest(String projectName, String bundleTag, String version, - long nodeCount, long edgeCount, int filesAnalyzed, + RepositoryIdentity repoIdentity, + long nodeCount, long edgeCount, boolean includesSource, boolean includesJar) { - Map m = new LinkedHashMap<>(); - m.put("bundle_format", 2); - m.put("tag", bundleTag); - m.put("project", projectName); - m.put("osscodeiq_version", version); - m.put("created_at", Instant.now().toString()); - m.put("backend", "neo4j"); - m.put("node_count", nodeCount); - m.put("edge_count", edgeCount); - m.put("files_analyzed", filesAnalyzed); - m.put("includes_source", includesSource); - m.put("includes_jar", includesJar); - - String gitSha = getGitSha(); - if (gitSha != null) m.put("git_sha", gitSha); - + var manifest = new ArtifactManifest( + ArtifactManifest.BUNDLE_FORMAT_VERSION, + bundleTag, + projectName, + version, + io.github.randomcodespace.iq.intelligence.Provenance.CURRENT_SCHEMA_VERSION, + Instant.now().toString(), + repoIdentity, + FileInventory.EMPTY.toSummary(), + nodeCount, + edgeCount, + includesSource, + includesJar, + null + ); try { return new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT) - .writeValueAsString(m); + .writeValueAsString(manifest.toMap()); } catch (Exception e) { return "{}"; } @@ -464,17 +467,4 @@ private void writeEntry(ZipOutputStream zos, String name, String content, String writeEntry(zos, name, content); } - private String getGitSha() { - try { - ProcessBuilder pb = new ProcessBuilder("git", "rev-parse", "HEAD") - .directory(path.toAbsolutePath().normalize().toFile()) - .redirectErrorStream(true); - Process proc = pb.start(); - String sha = new String(proc.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); - int exitCode = proc.waitFor(); - return (exitCode == 0 && sha.length() >= 7) ? sha : null; - } catch (Exception ignored) { - return null; - } - } } diff --git a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java index 5c88e108..0a179140 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java @@ -5,6 +5,8 @@ import io.github.randomcodespace.iq.analyzer.linker.Linker; import io.github.randomcodespace.iq.cache.AnalysisCache; import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalEnricher; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; import io.github.randomcodespace.iq.model.EdgeKind; @@ -58,11 +60,14 @@ public class EnrichCommand implements Callable { private final CodeIqConfig config; private final LayerClassifier layerClassifier; private final List linkers; + private final LexicalEnricher lexicalEnricher; - public EnrichCommand(CodeIqConfig config, LayerClassifier layerClassifier, List linkers) { + public EnrichCommand(CodeIqConfig config, LayerClassifier layerClassifier, + List linkers, LexicalEnricher lexicalEnricher) { this.config = config; this.layerClassifier = layerClassifier; this.linkers = linkers; + this.lexicalEnricher = lexicalEnricher; } @Override @@ -116,7 +121,8 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins // 2. Run linkers (these work on in-memory node/edge lists) CliOutput.step("\uD83D\uDD17", "Running cross-file linkers..."); - var builder = new GraphBuilder(); + RepositoryIdentity repoIdentity = RepositoryIdentity.resolve(root); + var builder = new GraphBuilder(repoIdentity, VersionCommand.VERSION); for (CodeNode node : allNodes) { builder.addNodes(List.of(node)); } @@ -141,12 +147,17 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins CliOutput.step("\uD83C\uDFF7\uFE0F", "Classifying layers..."); layerClassifier.classify(enrichedNodes); - // 3b. Detect services + // 3b. Enrich lexical metadata (doc comments, config keys) for fulltext search + CliOutput.step("\uD83D\uDD0D", "Enriching lexical metadata..."); + lexicalEnricher.enrich(enrichedNodes, root); + + // 3c. Detect services CliOutput.step("\uD83C\uDFD7\uFE0F", "Detecting service boundaries..."); var serviceDetector = new io.github.randomcodespace.iq.analyzer.ServiceDetector(); String projectName = root.getFileName().toString(); var serviceResult = serviceDetector.detect(enrichedNodes, enrichedEdges, projectName, root); if (!serviceResult.serviceNodes().isEmpty()) { + serviceResult.serviceNodes().forEach(n -> n.setProvenance(builder.getProvenance())); // Add service nodes and edges to the builder builder.addNodes(serviceResult.serviceNodes()); builder.addEdges(serviceResult.serviceEdges()); @@ -312,6 +323,9 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins tx.execute("CREATE FULLTEXT INDEX search_index IF NOT EXISTS " + "FOR (n:CodeNode) ON EACH [n.label_lower, n.fqn_lower] " + "OPTIONS {indexConfig: {`fulltext.analyzer`: 'keyword'}}"); + tx.execute("CREATE FULLTEXT INDEX lexical_index IF NOT EXISTS " + + "FOR (n:CodeNode) ON EACH [n.prop_lex_comment, n.prop_lex_config_keys] " + + "OPTIONS {indexConfig: {`fulltext.analyzer`: 'standard'}}"); tx.commit(); } // Wait for all indexes (including fulltext) to finish building diff --git a/src/main/java/io/github/randomcodespace/iq/cli/VersionCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/VersionCommand.java index 57e69829..e773c677 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/VersionCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/VersionCommand.java @@ -16,7 +16,7 @@ description = "Show version info") public class VersionCommand implements Callable { - static final String VERSION = "0.1.0-SNAPSHOT"; + public static final String VERSION = "0.1.0-SNAPSHOT"; private final DetectorRegistry registry; diff --git a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java index 9e14a7eb..d7f2d50b 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java +++ b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java @@ -1,7 +1,22 @@ package io.github.randomcodespace.iq.config; +import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; +import io.github.randomcodespace.iq.graph.GraphStore; +import io.github.randomcodespace.iq.intelligence.ArtifactManifest; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import io.github.randomcodespace.iq.intelligence.query.CapabilityMatrix; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; /** * Configuration properties for OSSCodeIQ, bound to the "codeiq" prefix. @@ -34,6 +49,9 @@ public class CodeIqConfig { /** Whether to serve the React web UI. Set to false via --no-ui flag. */ private boolean uiEnabled = true; + /** Maximum lines per snippet returned in evidence packs (default 50). */ + private int maxSnippetLines = 50; + public static class Graph { private String path = ".osscodeiq/graph.db"; @@ -117,4 +135,60 @@ public boolean isUiEnabled() { public void setUiEnabled(boolean uiEnabled) { this.uiEnabled = uiEnabled; } + + public int getMaxSnippetLines() { + return maxSnippetLines; + } + + public void setMaxSnippetLines(int maxSnippetLines) { + this.maxSnippetLines = Math.max(1, maxSnippetLines); + } + + /** + * Provides {@link ArtifactMetadata} as a Spring bean in the {@code serving} profile. + * + *

Metadata is derived at serve-startup from the analysed repository and the + * populated Neo4j graph. {@code graphStore} is optional so serve can start even + * when the graph has not been populated yet (the manifest endpoint returns 503 in + * that case, handled by {@code IntelligenceController}). + */ + @Bean + @Profile("serving") + public ArtifactMetadata artifactMetadata( + @Autowired(required = false) GraphStore graphStore) { + Path root = Path.of(rootPath).toAbsolutePath().normalize(); + RepositoryIdentity identity = RepositoryIdentity.resolve(root); + + long nodeCount = 0L; + long edgeCount = 0L; + if (graphStore != null) { + try { + nodeCount = graphStore.count(); + edgeCount = graphStore.countEdges(); + } catch (Exception ignored) { + // Graph not yet populated — counts stay zero + } + } + + String integrityHash = ArtifactMetadata.computeIntegrityHash( + nodeCount, edgeCount, identity.commitSha()); + + Map> langCaps = new LinkedHashMap<>(); + CapabilityMatrix.asSerializableMap().forEach((lang, dims) -> { + Map dimMap = new LinkedHashMap<>(); + dims.forEach((dim, level) -> dimMap.put(dim, CapabilityLevel.valueOf(level))); + langCaps.put(lang, Collections.unmodifiableMap(dimMap)); + }); + + return new ArtifactMetadata( + identity.repoUrl() != null ? identity.repoUrl() : root.toString(), + identity.commitSha(), + identity.buildTimestamp(), + String.valueOf(Provenance.CURRENT_SCHEMA_VERSION), + String.valueOf(ArtifactManifest.BUNDLE_FORMAT_VERSION), + Map.of("code-iq", "phase-4"), + Collections.unmodifiableMap(langCaps), + integrityHash + ); + } } diff --git a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java index acfa1521..6b0a8bb8 100644 --- a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java +++ b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java @@ -92,6 +92,9 @@ public void bulkSave(List nodes) { tx.execute("CREATE FULLTEXT INDEX search_index IF NOT EXISTS " + "FOR (n:CodeNode) ON EACH [n.label_lower, n.fqn_lower] " + "OPTIONS {indexConfig: {`fulltext.analyzer`: 'keyword'}}"); + tx.execute("CREATE FULLTEXT INDEX lexical_index IF NOT EXISTS " + + "FOR (n:CodeNode) ON EACH [n.prop_lex_comment, n.prop_lex_config_keys] " + + "OPTIONS {indexConfig: {`fulltext.analyzer`: 'standard'}}"); tx.commit(); } @@ -264,6 +267,18 @@ public List search(String text, int limit) { Map.of("text", toLuceneQuery(text), "limit", limit)); } + /** + * Search the lexical index ({@code prop_lex_comment} and {@code prop_lex_config_keys}) + * for nodes matching {@code text}. Used by {@code LexicalQueryService} for doc comment + * and config key retrieval. + */ + public List searchLexical(String text, int limit) { + return queryNodes( + "CALL db.index.fulltext.queryNodes('lexical_index', $text) " + + "YIELD node RETURN node AS n LIMIT $limit", + Map.of("text", toLuceneQuery(text), "limit", limit)); + } + /** * Wraps a search term in Lucene wildcard syntax for substring matching against * the fulltext index (which stores lowercased property values via keyword analyzer). diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/ArtifactManifest.java b/src/main/java/io/github/randomcodespace/iq/intelligence/ArtifactManifest.java new file mode 100644 index 00000000..77f00f89 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/ArtifactManifest.java @@ -0,0 +1,80 @@ +package io.github.randomcodespace.iq.intelligence; + +import java.util.Map; + +/** + * Artifact manifest — extends the bundle manifest with repository identity, + * schema version, extractor version, file inventory summary, and integrity checksums. + * + * @param bundleFormat Always {@code 2} for this record. + * @param tag User-supplied bundle tag (may be null). + * @param project Project name. + * @param extractorVersion Version of the code-iq extractor that built this bundle. + * @param schemaVersion Graph schema version at bundle time. + * @param createdAt ISO-8601 timestamp. + * @param repositoryIdentity Git/VCS identity of the analysed repo. + * @param fileInventorySummary Summary from {@link FileInventory#toSummary()}. + * @param nodeCount Total graph nodes. + * @param edgeCount Total graph edges. + * @param includesSource Whether source files are bundled. + * @param includesJar Whether the CLI JAR is bundled. + * @param checksums SHA-256 digests of key bundle entries (entry → hex digest). + */ +public record ArtifactManifest( + int bundleFormat, + String tag, + String project, + String extractorVersion, + int schemaVersion, + String createdAt, + RepositoryIdentity repositoryIdentity, + Map fileInventorySummary, + long nodeCount, + long edgeCount, + boolean includesSource, + boolean includesJar, + Map checksums +) { + public static final int BUNDLE_FORMAT_VERSION = 2; + + /** + * Serialise to a JSON-friendly {@link Map} (preserves insertion order). + * Null/empty fields are omitted for a clean manifest. + */ + public Map toMap() { + var m = new java.util.LinkedHashMap(); + m.put("bundle_format", bundleFormat); + if (tag != null) m.put("tag", tag); + m.put("project", project); + m.put("extractor_version", extractorVersion); + m.put("schema_version", schemaVersion); + m.put("created_at", createdAt); + + // Repository identity + if (repositoryIdentity != null) { + var ri = new java.util.LinkedHashMap(); + if (repositoryIdentity.repoUrl() != null) ri.put("repo_url", repositoryIdentity.repoUrl()); + if (repositoryIdentity.commitSha() != null) ri.put("commit_sha", repositoryIdentity.commitSha()); + if (repositoryIdentity.branch() != null) ri.put("branch", repositoryIdentity.branch()); + if (repositoryIdentity.buildTimestamp() != null) + ri.put("build_timestamp", repositoryIdentity.buildTimestamp().toString()); + if (!ri.isEmpty()) m.put("repository", ri); + } + + // File inventory summary + if (fileInventorySummary != null && !fileInventorySummary.isEmpty()) { + m.put("file_inventory", fileInventorySummary); + } + + m.put("backend", "neo4j"); + m.put("node_count", nodeCount); + m.put("edge_count", edgeCount); + m.put("includes_source", includesSource); + m.put("includes_jar", includesJar); + + if (checksums != null && !checksums.isEmpty()) { + m.put("checksums", checksums); + } + return m; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/CapabilityLevel.java b/src/main/java/io/github/randomcodespace/iq/intelligence/CapabilityLevel.java new file mode 100644 index 00000000..9ee25550 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/CapabilityLevel.java @@ -0,0 +1,16 @@ +package io.github.randomcodespace.iq.intelligence; + +/** + * Confidence level for intelligence capabilities on a given language or feature. + * Used in provenance records and capability matrix entries. + */ +public enum CapabilityLevel { + /** Full semantic understanding — AST-level, cross-file, high confidence. */ + EXACT, + /** Partial coverage — some constructs detected, others may be missed. */ + PARTIAL, + /** Lexical/text search only — no structural analysis. */ + LEXICAL_ONLY, + /** Language or feature is not supported by current extractors. */ + UNSUPPORTED +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/FileClassification.java b/src/main/java/io/github/randomcodespace/iq/intelligence/FileClassification.java new file mode 100644 index 00000000..814cd0c5 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/FileClassification.java @@ -0,0 +1,18 @@ +package io.github.randomcodespace.iq.intelligence; + +/** + * Heuristic classification of a file's role in the repository. + * Determined by file extension and path conventions. + */ +public enum FileClassification { + /** Production source code. */ + SOURCE, + /** Configuration files (YAML, JSON, TOML, properties, etc.). */ + CONFIG, + /** Documentation (Markdown, AsciiDoc, plain text, etc.). */ + DOC, + /** Test code (paths containing test/, spec/, __tests__, etc.). */ + TEST, + /** Generated code (paths containing generated/, build/, target/, etc.). */ + GENERATED +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/FileEntry.java b/src/main/java/io/github/randomcodespace/iq/intelligence/FileEntry.java new file mode 100644 index 00000000..f2edc475 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/FileEntry.java @@ -0,0 +1,61 @@ +package io.github.randomcodespace.iq.intelligence; + +/** + * A single file record in the {@link FileInventory}. + * + * @param path Repository-relative path (forward-slash normalised). + * @param language Detected language (lower-case, e.g. "java", "typescript"). + * @param sizeBytes File size in bytes at discovery time. + * @param contentHash SHA-256 hex digest of the file content (may be null for large/skipped files). + * @param classification Heuristic role of the file. + */ +public record FileEntry( + String path, + String language, + long sizeBytes, + String contentHash, + FileClassification classification +) implements Comparable { + + @Override + public int compareTo(FileEntry other) { + return this.path.compareTo(other.path); + } + + /** + * Classify a file by its path and language. + * Rules applied in order — first match wins. + */ + public static FileClassification classify(String relPath, String language) { + String lower = relPath.replace('\\', '/').toLowerCase(); + + // Generated paths + if (lower.contains("/generated/") || lower.contains("/target/") + || lower.contains("/build/") || lower.contains("/dist/") + || lower.contains("/out/") || lower.contains("/.gradle/")) { + return FileClassification.GENERATED; + } + // Test paths + if (lower.contains("/test/") || lower.contains("/tests/") + || lower.contains("/spec/") || lower.contains("/__tests__/") + || lower.endsWith("test.java") || lower.endsWith("tests.java") + || lower.endsWith(".test.ts") || lower.endsWith(".spec.ts") + || lower.endsWith(".test.js") || lower.endsWith(".spec.js") + || lower.endsWith("_test.go") || lower.endsWith("_test.py")) { + return FileClassification.TEST; + } + // Documentation + if (lower.contains("/docs/") || lower.contains("/doc/") + || lower.endsWith(".md") || lower.endsWith(".adoc") + || lower.endsWith(".rst") || lower.endsWith(".txt")) { + return FileClassification.DOC; + } + // Config by language + if ("yaml".equals(language) || "json".equals(language) || "toml".equals(language) + || "ini".equals(language) || "properties".equals(language) + || "xml".equals(language) || lower.endsWith(".env")) { + return FileClassification.CONFIG; + } + return FileClassification.SOURCE; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java b/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java new file mode 100644 index 00000000..3a3b0f31 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/FileInventory.java @@ -0,0 +1,72 @@ +package io.github.randomcodespace.iq.intelligence; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * Deterministic inventory of all files discovered in a repository. + * Entries are always sorted by path for reproducibility. + * + * @param entries Sorted, immutable list of file entries. + */ +public record FileInventory(List entries) { + + /** Canonical constructor — sorts and makes the list immutable. */ + public FileInventory(List entries) { + var sorted = entries.stream().sorted().toList(); + this.entries = Collections.unmodifiableList(sorted); + } + + /** Total number of files. */ + public int totalFiles() { + return entries.size(); + } + + /** Count of files per {@link FileClassification}, sorted by name for determinism. */ + public Map countsByClassification() { + return entries.stream() + .collect(Collectors.groupingBy(FileEntry::classification, + TreeMap::new, Collectors.counting())); + } + + /** Count of files per language, sorted alphabetically for determinism. */ + public Map countsByLanguage() { + return entries.stream() + .collect(Collectors.groupingBy(FileEntry::language, + TreeMap::new, Collectors.counting())); + } + + /** Sum of all file sizes in bytes. */ + public long totalBytes() { + return entries.stream().mapToLong(FileEntry::sizeBytes).sum(); + } + + /** + * Build a compact summary map suitable for inclusion in the v3 manifest. + */ + public Map toSummary() { + var summary = new java.util.LinkedHashMap(); + summary.put("total_files", totalFiles()); + summary.put("total_bytes", totalBytes()); + var byCls = countsByClassification().entrySet().stream() + .collect(Collectors.toMap( + e -> e.getKey().name().toLowerCase(), + Map.Entry::getValue, + (a, b) -> a, + java.util.LinkedHashMap::new)); // preserve TreeMap insertion order + summary.put("by_classification", byCls); + var byLang = countsByLanguage().entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed() + .thenComparing(Map.Entry.comparingByKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, + (a, b) -> a, java.util.LinkedHashMap::new)); + summary.put("by_language", byLang); + return summary; + } + + /** Empty inventory constant. */ + public static final FileInventory EMPTY = new FileInventory(List.of()); +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/Provenance.java b/src/main/java/io/github/randomcodespace/iq/intelligence/Provenance.java new file mode 100644 index 00000000..a4731bcb --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/Provenance.java @@ -0,0 +1,65 @@ +package io.github.randomcodespace.iq.intelligence; + +/** + * Provenance metadata attached to every CodeNode in the intelligence graph. + * Stored in the node's {@code properties} map using {@code prov_*} keys. + * + * @param repositoryUrl Remote URL of the repository (may be null for local-only analysis). + * @param commitSha Full SHA-1 of the HEAD commit at analysis time (may be null). + * @param extractorVersion Version of the code-iq extractor that produced this node. + * @param schemaVersion Graph schema version (integer, incremented on breaking changes). + * @param confidence Capability level for the language/feature that produced this node. + */ +public record Provenance( + String repositoryUrl, + String commitSha, + String extractorVersion, + int schemaVersion, + CapabilityLevel confidence +) { + /** Current graph schema version. Increment on any breaking schema change. */ + public static final int CURRENT_SCHEMA_VERSION = 1; + + // --- Property map keys (prov_* prefix for Neo4j round-trip) --- + public static final String KEY_REPO_URL = "prov_repo_url"; + public static final String KEY_COMMIT_SHA = "prov_commit_sha"; + public static final String KEY_EXTRACTOR_VER = "prov_extractor_version"; + public static final String KEY_SCHEMA_VER = "prov_schema_version"; + public static final String KEY_CONFIDENCE = "prov_confidence"; + + /** + * Write this provenance into a node's properties map. + * Null-valued fields are skipped to avoid polluting the map. + */ + public java.util.Map toProperties() { + var map = new java.util.LinkedHashMap(); + if (repositoryUrl != null) map.put(KEY_REPO_URL, repositoryUrl); + if (commitSha != null) map.put(KEY_COMMIT_SHA, commitSha); + map.put(KEY_EXTRACTOR_VER, extractorVersion); + map.put(KEY_SCHEMA_VER, schemaVersion); + map.put(KEY_CONFIDENCE, confidence.name()); + return map; + } + + /** + * Reconstruct a Provenance from a node's properties map. + * Returns null if provenance keys are absent. + */ + public static Provenance fromProperties(java.util.Map props) { + if (props == null || !props.containsKey(KEY_EXTRACTOR_VER)) return null; + String repoUrl = (String) props.get(KEY_REPO_URL); + String sha = (String) props.get(KEY_COMMIT_SHA); + String extVer = (String) props.getOrDefault(KEY_EXTRACTOR_VER, "unknown"); + Object schemaVerObj = props.getOrDefault(KEY_SCHEMA_VER, CURRENT_SCHEMA_VERSION); + int schemaVer = schemaVerObj instanceof Number n ? n.intValue() + : Integer.parseInt(schemaVerObj.toString()); + String confStr = (String) props.getOrDefault(KEY_CONFIDENCE, CapabilityLevel.PARTIAL.name()); + CapabilityLevel confidence; + try { + confidence = CapabilityLevel.valueOf(confStr); + } catch (IllegalArgumentException e) { + confidence = CapabilityLevel.PARTIAL; + } + return new Provenance(repoUrl, sha, extVer, schemaVer, confidence); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java b/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java new file mode 100644 index 00000000..5c6880f8 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentity.java @@ -0,0 +1,55 @@ +package io.github.randomcodespace.iq.intelligence; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +/** + * Identity snapshot of a repository at analysis time. + * Populated from git metadata during the {@code index} command. + * + * @param repoUrl Remote origin URL (null for local-only repos). + * @param commitSha Full SHA-1 of HEAD (null if not a git repo). + * @param branch Current branch name (null if detached HEAD or not git). + * @param buildTimestamp When the analysis run started. + */ +public record RepositoryIdentity( + String repoUrl, + String commitSha, + String branch, + Instant buildTimestamp +) { + /** + * Resolve repository identity from a local path using git commands. + * Fields that cannot be determined are set to null gracefully. + */ + public static RepositoryIdentity resolve(java.nio.file.Path repoPath) { + String repoUrl = runGit(repoPath, "remote", "get-url", "origin"); + String commitSha = runGit(repoPath, "rev-parse", "HEAD"); + String branch = runGit(repoPath, "rev-parse", "--abbrev-ref", "HEAD"); + // Detached HEAD produces "HEAD" rather than a branch name — normalise to null + if ("HEAD".equals(branch)) branch = null; + return new RepositoryIdentity(repoUrl, commitSha, branch, Instant.now()); + } + + /** Returns null on any error. */ + private static String runGit(java.nio.file.Path repoPath, String... args) { + try { + var cmd = new java.util.ArrayList(); + cmd.add("git"); + cmd.addAll(java.util.Arrays.asList(args)); + var pb = new ProcessBuilder(cmd) + .directory(repoPath.toFile()) + .redirectErrorStream(true); + var proc = pb.start(); + try (var is = proc.getInputStream()) { + String out = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim(); + int exit = proc.waitFor(); + return (exit == 0 && !out.isBlank()) ? out : null; + } finally { + proc.destroy(); + } + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePack.java b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePack.java new file mode 100644 index 00000000..1b389f89 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePack.java @@ -0,0 +1,41 @@ +package io.github.randomcodespace.iq.intelligence.evidence; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.lexical.CodeSnippet; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import io.github.randomcodespace.iq.model.CodeNode; + +import java.util.List; + +/** + * Runtime-facing evidence pack: everything the caller needs to understand a symbol or file. + * + * @param matchedSymbols Nodes whose name matched the requested symbol or file. + * @param relatedFiles File paths of related nodes discovered via cross-references. + * @param references Related nodes discovered via cross-reference traversal (non-empty when + * {@link EvidencePackRequest#includeReferences()} is true). + * @param snippets Bounded source snippets extracted for matched symbols. + * @param provenance Provenance maps (one per matched node; may be null entries). + * @param degradationNotes Human-readable notes explaining capability gaps; empty list when fully capable. + * @param artifactMetadata Runtime projection of the artifact manifest. + * @param capabilityLevel Overall capability level for the primary language of the matched symbols. + */ +public record EvidencePack( + List matchedSymbols, + List relatedFiles, + List references, + List snippets, + List> provenance, + List degradationNotes, + ArtifactMetadata artifactMetadata, + CapabilityLevel capabilityLevel +) { + /** Returns an empty evidence pack — used when no symbols are found. */ + public static EvidencePack empty(ArtifactMetadata artifactMetadata, String degradationNote) { + List notes = degradationNote != null ? List.of(degradationNote) : List.of(); + return new EvidencePack( + List.of(), List.of(), List.of(), List.of(), List.of(), + notes, artifactMetadata, CapabilityLevel.UNSUPPORTED + ); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssembler.java b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssembler.java new file mode 100644 index 00000000..876837be --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssembler.java @@ -0,0 +1,253 @@ +package io.github.randomcodespace.iq.intelligence.evidence; + +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.graph.GraphStore; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.lexical.CodeSnippet; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalQueryService; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalResult; +import io.github.randomcodespace.iq.intelligence.lexical.SnippetStore; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import io.github.randomcodespace.iq.intelligence.query.QueryPlan; +import io.github.randomcodespace.iq.intelligence.query.QueryPlanner; +import io.github.randomcodespace.iq.intelligence.query.QueryRoute; +import io.github.randomcodespace.iq.intelligence.query.QueryType; +import io.github.randomcodespace.iq.model.CodeNode; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Assembles {@link EvidencePack} instances from query plans and lexical results. + * + *

Stateless and thread-safe — all state is method-local. Active only in the + * {@code serving} profile. + */ +@Service +@Profile("serving") +public class EvidencePackAssembler { + + private final LexicalQueryService lexicalQueryService; + private final SnippetStore snippetStore; + private final QueryPlanner queryPlanner; + private final CodeIqConfig config; + private final GraphStore graphStore; + + public EvidencePackAssembler(LexicalQueryService lexicalQueryService, + SnippetStore snippetStore, + QueryPlanner queryPlanner, + CodeIqConfig config, + GraphStore graphStore) { + this.lexicalQueryService = lexicalQueryService; + this.snippetStore = snippetStore; + this.queryPlanner = queryPlanner; + this.config = config; + this.graphStore = graphStore; + } + + /** + * Assemble an evidence pack for the given request. + * + *

When no symbols are found, returns an empty pack with a degradation note + * rather than throwing an exception. + * + * @param request the evidence pack request + * @param artifactMetadata provenance metadata loaded at serve startup + * @return a fully-assembled (possibly empty) evidence pack; never {@code null} + */ + public EvidencePack assemble(EvidencePackRequest request, ArtifactMetadata artifactMetadata) { + int maxLines = resolveMaxLines(request.maxSnippetLines()); + Path rootPath = Path.of(config.getRootPath()).toAbsolutePath().normalize(); + + // Resolve query subject — prefer symbol, fall back to filePath + String subject = request.symbol() != null && !request.symbol().isBlank() + ? request.symbol().strip() + : (request.filePath() != null ? request.filePath().strip() : null); + + if (subject == null) { + return EvidencePack.empty(artifactMetadata, "No symbol or file path provided."); + } + + // Determine language from filePath when available (for query planner) + String language = request.filePath() != null + ? inferLanguage(request.filePath()) + : "unknown"; + + // Plan the query + QueryPlan plan = queryPlanner.plan(QueryType.FIND_SYMBOL, language); + + // Execute lexical lookup + List lexResults = lexicalQueryService.findByIdentifier(subject); + + if (lexResults.isEmpty()) { + String degradationNote = buildEmptyNote(subject, plan); + return EvidencePack.empty(artifactMetadata, degradationNote); + } + + // Collect matched symbols (deterministic order) + List matchedSymbols = lexResults.stream() + .map(LexicalResult::node) + .toList(); + + // Extract snippets bounded by maxLines + List snippets = new ArrayList<>(); + for (LexicalResult lr : lexResults) { + CodeNode node = lr.node(); + Optional snippet = snippetStore.extract(node, rootPath); + snippet.map(s -> boundSnippet(s, maxLines)).ifPresent(snippets::add); + } + + // Collect related files (sorted for determinism) + Set relatedFilesSet = new LinkedHashSet<>(); + for (CodeNode node : matchedSymbols) { + if (node.getFilePath() != null) relatedFilesSet.add(node.getFilePath()); + } + List relatedFiles = new ArrayList<>(relatedFilesSet); + relatedFiles.sort(String::compareTo); + + // References: fetch related symbols from the same files when requested + List references = List.of(); + if (request.includeReferences()) { + references = fetchReferences(matchedSymbols, subject); + } + + // Build provenance list (parallel to matchedSymbols) + List> provenanceList = matchedSymbols.stream() + .map(n -> { + Map m = new LinkedHashMap<>(); + if (n.getFilePath() != null) m.put("filePath", n.getFilePath()); + if (n.getLineStart() != null) m.put("lineStart", n.getLineStart()); + if (n.getLineEnd() != null) m.put("lineEnd", n.getLineEnd()); + m.put("kind", n.getKind() != null ? n.getKind().getValue() : "unknown"); + if (n.getProperties() != null) { + n.getProperties().forEach((k, v) -> { + if (k.startsWith("prov_") && v != null) m.put(k, v); + }); + } + return (Map) m; + }) + .toList(); + + // Degradation notes + List degradationNotes = buildDegradationNotes(plan); + + // Overall capability level = worst across matched symbols + CapabilityLevel capLevel = deriveCapabilityLevel(plan); + + return new EvidencePack( + matchedSymbols, + relatedFiles, + references, + snippets, + provenanceList, + degradationNotes, + artifactMetadata, + capLevel + ); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private int resolveMaxLines(Integer requested) { + int configured = config.getMaxSnippetLines(); + if (requested == null) return configured; + return Math.min(Math.max(1, requested), configured); + } + + /** + * Truncate a snippet to {@code maxLines} lines, centred on the symbol start. + * Returns the original snippet when already within bounds. + */ + private CodeSnippet boundSnippet(CodeSnippet snippet, int maxLines) { + String[] lines = snippet.sourceText().split("\n", -1); + if (lines.length <= maxLines) return snippet; + + // Take first maxLines lines + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < maxLines; i++) { + sb.append(lines[i]).append('\n'); + } + return new CodeSnippet( + sb.toString(), + snippet.filePath(), + snippet.lineStart(), + snippet.lineStart() + maxLines - 1, + snippet.language(), + snippet.provenance() + ); + } + + private List fetchReferences(List matchedSymbols, String subject) { + // Traverse CALLS and DEPENDS_ON edges to find nodes that actually reference these symbols + Set matchedIds = new LinkedHashSet<>(); + for (CodeNode n : matchedSymbols) { + if (n.getId() != null) matchedIds.add(n.getId()); + } + Set seen = new LinkedHashSet<>(matchedIds); + List references = new ArrayList<>(); + for (CodeNode n : matchedSymbols) { + if (n.getId() == null) continue; + for (CodeNode caller : graphStore.findCallers(n.getId())) { + if (caller.getId() != null && seen.add(caller.getId())) { + references.add(caller); + } + } + for (CodeNode dependent : graphStore.findDependents(n.getId())) { + if (dependent.getId() != null && seen.add(dependent.getId())) { + references.add(dependent); + } + } + } + return references; + } + + private String buildEmptyNote(String subject, QueryPlan plan) { + if (plan.route() == QueryRoute.DEGRADED) { + return plan.degradationNote() != null ? plan.degradationNote() + : "Symbol '" + subject + "' not found. Language is not fully supported."; + } + return "Symbol '" + subject + "' was not found in the indexed graph."; + } + + private List buildDegradationNotes(QueryPlan plan) { + if (plan.degradationNote() != null) { + return List.of(plan.degradationNote()); + } + return List.of(); + } + + private CapabilityLevel deriveCapabilityLevel(QueryPlan plan) { + return switch (plan.route()) { + case GRAPH_FIRST -> CapabilityLevel.EXACT; + case MERGED -> CapabilityLevel.PARTIAL; + case LEXICAL_FIRST -> CapabilityLevel.LEXICAL_ONLY; + case DEGRADED -> CapabilityLevel.UNSUPPORTED; + }; + } + + private static String inferLanguage(String filePath) { + if (filePath == null) return "unknown"; + int dot = filePath.lastIndexOf('.'); + if (dot < 0) return "unknown"; + return switch (filePath.substring(dot + 1).toLowerCase()) { + case "java" -> "java"; + case "ts", "tsx" -> "typescript"; + case "js", "jsx" -> "javascript"; + case "py" -> "python"; + case "go" -> "go"; + case "rs" -> "rust"; + case "cs" -> "csharp"; + default -> "unknown"; + }; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackRequest.java b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackRequest.java new file mode 100644 index 00000000..8c60438a --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackRequest.java @@ -0,0 +1,21 @@ +package io.github.randomcodespace.iq.intelligence.evidence; + +/** + * Request parameters for assembling an evidence pack. + * + * @param symbol Symbol name to look up (e.g. "UserService", "handleLogin"). May be null if filePath provided. + * @param filePath Source file path relative to repo root. May be null if symbol provided. + * @param maxSnippetLines Maximum lines per snippet; null → use config default. + * @param includeReferences Whether to include cross-reference nodes in the pack. + */ +public record EvidencePackRequest( + String symbol, + String filePath, + Integer maxSnippetLines, + boolean includeReferences +) { + /** Returns true when neither symbol nor filePath are provided. */ + public boolean isEmpty() { + return (symbol == null || symbol.isBlank()) && (filePath == null || filePath.isBlank()); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/CodeSnippet.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/CodeSnippet.java new file mode 100644 index 00000000..9b77e151 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/CodeSnippet.java @@ -0,0 +1,22 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.intelligence.Provenance; + +/** + * A bounded code snippet extracted from a source file, produced by {@link SnippetStore}. + * + * @param sourceText Raw source text, bounded to at most {@link SnippetStore#MAX_LINES} lines. + * @param filePath Repo-relative path of the source file. + * @param lineStart 1-based start line of the extracted snippet. + * @param lineEnd 1-based end line of the extracted snippet (inclusive). + * @param language Lowercase language identifier (e.g. "java", "typescript"). + * @param provenance Provenance of the parent CodeNode; may be null. + */ +public record CodeSnippet( + String sourceText, + String filePath, + int lineStart, + int lineEnd, + String language, + Provenance provenance +) {} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractor.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractor.java new file mode 100644 index 00000000..bece90e1 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractor.java @@ -0,0 +1,179 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * Extracts doc comments from source files by scanning lines before (or just inside) + * a given declaration. + * + *

Supported styles: + *

    + *
  • Javadoc / JSDoc / C++ Doxygen — {@code /** ... * /} block immediately before the declaration.
  • + *
  • Python triple-quoted docstrings — first string literal inside the function/class body.
  • + *
  • Go / Rust / TypeScript line comments — contiguous {@code //} lines ending at the declaration.
  • + *
+ * + *

All methods are static — this class has no state. + */ +public final class DocCommentExtractor { + + private DocCommentExtractor() {} + + /** + * Extract the doc comment for the symbol declared at {@code lineStart} in {@code file}. + * + * @param file Absolute path to the source file. + * @param language Lowercase language identifier (e.g. "java", "typescript", "python"). + * @param lineStart 1-based line number of the symbol declaration. + * @return Cleaned comment text, or null if none found or file unreadable. + */ + public static String extract(Path file, String language, int lineStart) { + if (file == null || language == null || lineStart <= 0) return null; + try { + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + if (lineStart > lines.size()) return null; + return switch (language) { + case "python" -> extractPythonDocstring(lines, lineStart); + case "go", "rust" -> extractLineComments(lines, lineStart); + default -> extractBlockComment(lines, lineStart); + }; + } catch (Exception e) { + return null; + } + } + + /** + * Extracts a {@code /** ... * /} block comment ending just before the declaration + * (skipping blank lines and annotation lines). + */ + private static String extractBlockComment(List lines, int lineStart) { + // Walk backwards from the declaration line, skip blanks and annotations + int scanIdx = lineStart - 2; // convert to 0-based, start one line before declaration + while (scanIdx >= 0) { + String trimmed = lines.get(scanIdx).trim(); + if (trimmed.isBlank() || trimmed.startsWith("@")) { + scanIdx--; + continue; + } + break; + } + if (scanIdx < 0) return null; + + String endLine = lines.get(scanIdx).trim(); + if (!endLine.endsWith("*/")) return null; + + // Find the matching opening /* or /** + int openIdx = scanIdx; + while (openIdx >= 0 && !lines.get(openIdx).trim().startsWith("/*")) { + openIdx--; + } + if (openIdx < 0) return null; + + // Collect and clean the comment block + var sb = new StringBuilder(); + for (int i = openIdx; i <= scanIdx; i++) { + String cleaned = lines.get(i).trim() + .replaceAll("^/\\*+\\s*", "") + .replaceAll("\\s*\\*/$", "") + .replaceAll("^\\*\\s?", "") + .trim(); + if (!cleaned.isBlank()) { + if (!sb.isEmpty()) sb.append(' '); + sb.append(cleaned); + } + } + return sb.isEmpty() ? null : sb.toString(); + } + + /** + * Extracts contiguous {@code //} line comments immediately before the declaration. + * Used for Go and Rust doc comment styles. + */ + private static String extractLineComments(List lines, int lineStart) { + int scanIdx = lineStart - 2; // 0-based index of line before declaration + // Skip blank lines + while (scanIdx >= 0 && lines.get(scanIdx).trim().isBlank()) scanIdx--; + + if (scanIdx < 0) return null; + + // Collect contiguous // lines going upward + int endIdx = scanIdx; + while (scanIdx >= 0 && lines.get(scanIdx).trim().startsWith("//")) { + scanIdx--; + } + int startIdx = scanIdx + 1; + if (startIdx > endIdx) return null; + + var sb = new StringBuilder(); + for (int i = startIdx; i <= endIdx; i++) { + String cleaned = lines.get(i).trim() + .replaceAll("^//[!/]?\\s*", "") + .trim(); + if (!cleaned.isBlank()) { + if (!sb.isEmpty()) sb.append(' '); + sb.append(cleaned); + } + } + return sb.isEmpty() ? null : sb.toString(); + } + + /** + * Extracts a Python triple-quoted docstring from the first string literal + * inside the function/class body (the line immediately after the declaration). + */ + private static String extractPythonDocstring(List lines, int lineStart) { + // Python docstring starts at lineStart (0-based: lineStart is the def/class line) + // The body starts at lineStart (1-based lineStart + 1 = 0-based lineStart) + StringBuilder accumulated = null; + String openQuote = null; + + for (int i = lineStart; i < Math.min(lineStart + 15, lines.size()); i++) { + String line = lines.get(i).trim(); + if (accumulated == null) { + // Look for opening triple-quote + int idxDouble = line.indexOf("\"\"\""); + int idxSingle = line.indexOf("'''"); + int tripleIdx; + String quote; + if (idxDouble >= 0 && (idxSingle < 0 || idxDouble <= idxSingle)) { + tripleIdx = idxDouble; + quote = "\"\"\""; + } else if (idxSingle >= 0) { + tripleIdx = idxSingle; + quote = "'''"; + } else { + // No triple quote on this line — not a docstring line, stop + break; + } + openQuote = quote; + String after = line.substring(tripleIdx + 3); + int closingIdx = after.indexOf(quote); + if (closingIdx >= 0) { + // Single-line docstring + String content = after.substring(0, closingIdx).trim(); + return content.isBlank() ? null : content; + } + accumulated = new StringBuilder(after.trim()); + } else { + int closingIdx = line.indexOf(openQuote); + if (closingIdx >= 0) { + String before = line.substring(0, closingIdx).trim(); + if (!before.isBlank()) { + if (!accumulated.isEmpty()) accumulated.append(' '); + accumulated.append(before); + } + String result = accumulated.toString().trim(); + return result.isBlank() ? null : result; + } + if (!line.isBlank()) { + if (!accumulated.isEmpty()) accumulated.append(' '); + accumulated.append(line); + } + } + } + return null; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricher.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricher.java new file mode 100644 index 00000000..5d00d884 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricher.java @@ -0,0 +1,101 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; +import java.util.List; + +/** + * Enriches {@link CodeNode} instances with lexical metadata before Neo4j bulk-load. + * + *

Populates two properties in the node's {@code properties} map: + *

    + *
  • {@value #KEY_LEX_COMMENT} — extracted doc comment / docstring for the symbol.
  • + *
  • {@value #KEY_LEX_CONFIG_KEYS} — config key path for config-typed nodes.
  • + *
+ * + *

These are stored as {@code prop_lex_comment} and {@code prop_lex_config_keys} in Neo4j + * (via the {@code prop_*} round-trip convention) and indexed by {@code lexical_index}. + */ +@Component +public class LexicalEnricher { + + private static final Logger log = LoggerFactory.getLogger(LexicalEnricher.class); + + /** Property key for doc comment text stored in CodeNode.properties. */ + public static final String KEY_LEX_COMMENT = "lex_comment"; + + /** Property key for config key path stored in CodeNode.properties. */ + public static final String KEY_LEX_CONFIG_KEYS = "lex_config_keys"; + + /** + * Enrich all nodes with lexical metadata extracted from source files. + * + * @param nodes All enriched nodes (post-linker, post-classifier). + * @param rootPath Absolute root path of the analysed repository. + */ + public void enrich(List nodes, Path rootPath) { + int commented = 0; + int configKeyed = 0; + for (CodeNode node : nodes) { + if (enrichDocComment(node, rootPath)) commented++; + if (enrichConfigKeys(node)) configKeyed++; + } + log.info("Lexical enrichment: {} doc comments, {} config key entries indexed", + commented, configKeyed); + } + + /** + * Extract and store the doc comment for the given node. + * + * @return true if a comment was found and stored. + */ + private boolean enrichDocComment(CodeNode node, Path rootPath) { + if (node.getFilePath() == null || node.getLineStart() == null) return false; + if (!isDocCommentCandidate(node.getKind())) return false; + + String language = SnippetStore.inferLanguage(node.getFilePath()); + Path file = rootPath.resolve(node.getFilePath()).normalize(); + if (!file.startsWith(rootPath)) return false; // path traversal guard + + String comment = DocCommentExtractor.extract(file, language, node.getLineStart()); + if (comment != null && !comment.isBlank()) { + node.getProperties().put(KEY_LEX_COMMENT, comment); + return true; + } + return false; + } + + /** + * For config-typed nodes, store the label/fqn as the config key path. + * + * @return true if the node was a config node and the key was stored. + */ + private static boolean enrichConfigKeys(CodeNode node) { + if (node.getKind() != NodeKind.CONFIG_KEY + && node.getKind() != NodeKind.CONFIG_FILE + && node.getKind() != NodeKind.CONFIG_DEFINITION) { + return false; + } + String keyPath = node.getFqn() != null ? node.getFqn() : node.getLabel(); + if (keyPath != null && !keyPath.isBlank()) { + node.getProperties().put(KEY_LEX_CONFIG_KEYS, keyPath); + return true; + } + return false; + } + + /** True for node kinds that typically carry doc comments. */ + private static boolean isDocCommentCandidate(NodeKind kind) { + return switch (kind) { + case CLASS, ABSTRACT_CLASS, INTERFACE, ENUM, ANNOTATION_TYPE, + METHOD, ENDPOINT, ENTITY, SERVICE, REPOSITORY, + COMPONENT, GUARD, MIDDLEWARE -> true; + default -> false; + }; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalQueryService.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalQueryService.java new file mode 100644 index 00000000..567280f0 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalQueryService.java @@ -0,0 +1,107 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.graph.GraphStore; +import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +/** + * Lexical query service for identifier, doc comment, and config key retrieval. + * + *

Only active in the {@code serving} profile — lexical queries require a Neo4j + * graph produced by the {@code enrich} command. + */ +@Service +@Profile("serving") +public class LexicalQueryService { + + private static final int DEFAULT_LIMIT = 50; + private static final int MAX_LIMIT = 200; + + private final GraphStore graphStore; + private final SnippetStore snippetStore; + private final CodeIqConfig config; + + public LexicalQueryService(GraphStore graphStore, SnippetStore snippetStore, + CodeIqConfig config) { + this.graphStore = graphStore; + this.snippetStore = snippetStore; + this.config = config; + } + + /** + * Find nodes whose name (label or fully-qualified name) matches an identifier. + * Results are ranked by fulltext relevance. + * + * @param identifier Identifier name to look up (e.g. "UserService", "handleLogin"). + * @param limit Maximum number of results. + * @return Ranked list of lexical results carrying provenance. + */ + public List findByIdentifier(String identifier, int limit) { + List nodes = graphStore.search(identifier, Math.min(limit, MAX_LIMIT)); + return nodes.stream() + .map(n -> LexicalResult.of(n, 0f, "identifier")) + .toList(); + } + + /** Convenience overload using the default limit. */ + public List findByIdentifier(String identifier) { + return findByIdentifier(identifier, DEFAULT_LIMIT); + } + + /** + * Find nodes whose doc comment / docstring text matches the given query. + * Searches the {@code lexical_index} over {@code prop_lex_comment}. + * + * @param docQuery Natural-language or keyword query against doc comment text. + * @param limit Maximum number of results. + * @return Ranked list of lexical results with optional bounded snippets. + */ + public List findByDocComment(String docQuery, int limit) { + Path rootPath = Path.of(config.getRootPath()); + List nodes = graphStore.searchLexical(docQuery, Math.min(limit, MAX_LIMIT)); + return nodes.stream() + .map(n -> { + Optional snippet = snippetStore.extract(n, rootPath); + Provenance prov = Provenance.fromProperties(n.getProperties()); + return new LexicalResult(n, 0f, LexicalEnricher.KEY_LEX_COMMENT, + snippet.orElse(null), prov); + }) + .toList(); + } + + /** Convenience overload using the default limit. */ + public List findByDocComment(String docQuery) { + return findByDocComment(docQuery, DEFAULT_LIMIT); + } + + /** + * Find config nodes whose key path matches the given query. + * Results are filtered to config-typed NodeKinds only. + * + * @param keyQuery Partial or full config key path (e.g. "spring.datasource"). + * @param limit Maximum number of results. + * @return Ranked list of lexical results. + */ + public List findByConfigKey(String keyQuery, int limit) { + List nodes = graphStore.searchLexical(keyQuery, Math.min(limit, MAX_LIMIT)); + return nodes.stream() + .filter(n -> n.getKind() == NodeKind.CONFIG_KEY + || n.getKind() == NodeKind.CONFIG_FILE + || n.getKind() == NodeKind.CONFIG_DEFINITION) + .map(n -> LexicalResult.of(n, 0f, LexicalEnricher.KEY_LEX_CONFIG_KEYS)) + .toList(); + } + + /** Convenience overload using the default limit. */ + public List findByConfigKey(String keyQuery) { + return findByConfigKey(keyQuery, DEFAULT_LIMIT); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalResult.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalResult.java new file mode 100644 index 00000000..e3c6549d --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalResult.java @@ -0,0 +1,27 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.model.CodeNode; + +/** + * A single result from a lexical query, carrying relevance metadata and optional snippet. + * + * @param node The matched CodeNode. + * @param score Lucene relevance score (higher = more relevant); 0.0 for non-scored queries. + * @param matchedField The indexed field that matched: "identifier", "lex_comment", or "lex_config_keys". + * @param snippet Optional bounded code snippet for this node; null if not extracted. + * @param provenance Provenance extracted from the node's properties; may be null. + */ +public record LexicalResult( + CodeNode node, + float score, + String matchedField, + CodeSnippet snippet, + Provenance provenance +) { + /** Convenience factory — no snippet, provenance read from node properties. */ + public static LexicalResult of(CodeNode node, float score, String matchedField) { + return new LexicalResult(node, score, matchedField, null, + Provenance.fromProperties(node.getProperties())); + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStore.java b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStore.java new file mode 100644 index 00000000..7d147122 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStore.java @@ -0,0 +1,104 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.intelligence.Provenance; +import io.github.randomcodespace.iq.model.CodeNode; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +/** + * Extracts bounded code snippets from source files for a given {@link CodeNode}. + * + *

Snippets are bounded to at most {@value #MAX_LINES} lines to keep evidence + * packs compact. + */ +@Component +public class SnippetStore { + + /** Maximum lines in any extracted snippet. */ + public static final int MAX_LINES = 50; + + private static final int DEFAULT_CONTEXT_LINES = 3; + + /** + * Extract a code snippet for the given node using default context (±{@value #DEFAULT_CONTEXT_LINES} lines). + * + * @param node Source node; must have {@code filePath} and {@code lineStart}. + * @param rootPath Absolute root path of the repository being analysed. + * @return Snippet, or empty if the node has no location or the file cannot be read. + */ + public Optional extract(CodeNode node, Path rootPath) { + return extract(node, rootPath, DEFAULT_CONTEXT_LINES); + } + + /** + * Extract a code snippet for the given node with custom context lines. + * + * @param node Source node. + * @param rootPath Absolute repository root. + * @param contextLines Lines of context to add above and below the symbol range. + * @return Snippet, or empty if node has no location or the file cannot be read. + */ + public Optional extract(CodeNode node, Path rootPath, int contextLines) { + if (node.getFilePath() == null || node.getLineStart() == null) return Optional.empty(); + try { + Path file = rootPath.resolve(node.getFilePath()).normalize(); + if (!file.startsWith(rootPath)) return Optional.empty(); // path traversal guard + if (!Files.isRegularFile(file)) return Optional.empty(); + + String content = Files.readString(file, StandardCharsets.UTF_8); + String[] lines = content.split("\n", -1); + int totalLines = lines.length; + + int symStart = node.getLineStart(); + int symEnd = node.getLineEnd() != null ? node.getLineEnd() : symStart; + + // Compute extraction window with context, bounded by file length + int extractStart = Math.max(1, symStart - contextLines); + int extractEnd = Math.min(totalLines, symEnd + contextLines); + + // Enforce MAX_LINES cap — centre on the symbol definition + if (extractEnd - extractStart + 1 > MAX_LINES) { + int centre = (symStart + symEnd) / 2; + extractStart = Math.max(1, centre - MAX_LINES / 2); + extractEnd = Math.min(totalLines, extractStart + MAX_LINES - 1); + } + + // Build source text (lines are 1-based, array is 0-based) + var sb = new StringBuilder(); + for (int i = extractStart - 1; i < extractEnd; i++) { + sb.append(lines[i]).append('\n'); + } + + String language = inferLanguage(node.getFilePath()); + Provenance provenance = Provenance.fromProperties(node.getProperties()); + return Optional.of(new CodeSnippet(sb.toString(), node.getFilePath(), + extractStart, extractEnd, language, provenance)); + } catch (Exception e) { + return Optional.empty(); + } + } + + static String inferLanguage(String filePath) { + if (filePath == null) return "unknown"; + int dot = filePath.lastIndexOf('.'); + if (dot < 0) return "unknown"; + return switch (filePath.substring(dot + 1).toLowerCase()) { + case "java" -> "java"; + case "ts", "tsx" -> "typescript"; + case "js", "jsx" -> "javascript"; + case "py" -> "python"; + case "go" -> "go"; + case "rs" -> "rust"; + case "cs" -> "csharp"; + case "cpp", "cc", "cxx", + "h", "hpp" -> "cpp"; + case "kt" -> "kotlin"; + case "scala", "sc" -> "scala"; + default -> "unknown"; + }; + } +} diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/provenance/ArtifactMetadata.java b/src/main/java/io/github/randomcodespace/iq/intelligence/provenance/ArtifactMetadata.java new file mode 100644 index 00000000..c166bff5 --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/provenance/ArtifactMetadata.java @@ -0,0 +1,53 @@ +package io.github.randomcodespace.iq.intelligence.provenance; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Map; + +/** + * Runtime-facing projection of {@code ArtifactManifest}. + * Loaded once at {@code serve} startup; immutable and thread-safe. + * + *

The {@code integrityHash} is a SHA-256 digest of + * {@code nodeCount + "|" + edgeCount + "|" + commitSha}. + * + * @param repositoryIdentity Remote URL or local path of the analysed repository. + * @param commitSha Full SHA-1 of HEAD at analysis time (may be null). + * @param buildTimestamp When the {@code enrich} run completed. + * @param schemaVersion Graph schema version. + * @param artifactFormatVersion Bundle format version string. + * @param extractorVersions Map of extractor component name → version string. + * @param languageCapabilities Per-language capability matrix snapshot. + * @param integrityHash SHA-256 integrity hash (hex). + */ +public record ArtifactMetadata( + String repositoryIdentity, + String commitSha, + Instant buildTimestamp, + String schemaVersion, + String artifactFormatVersion, + Map extractorVersions, + Map> languageCapabilities, + String integrityHash +) { + /** + * Compute the integrity hash from graph counts and commit SHA. + * Returns a hex-encoded SHA-256 digest. + */ + public static String computeIntegrityHash(long nodeCount, long edgeCount, String commitSha) { + String canonical = nodeCount + "|" + edgeCount + "|" + (commitSha != null ? commitSha : ""); + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(canonical.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is guaranteed by the JDK + throw new IllegalStateException("SHA-256 unavailable", e); + } + } +} 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..6779478d --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/CapabilityMatrix.java @@ -0,0 +1,286 @@ +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 "cpp", "c++" -> CPP_CAPS; + case "rust" -> RUST_CAPS; + default -> { + if (LEXICAL_ONLY_LANGUAGES.contains(lang)) yield LEXICAL_ONLY_CAPS; + if (ANTLR_LANGUAGES.contains(lang)) yield CSHARP_CAPS; // cpp etc → PARTIAL + 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..ffae298f --- /dev/null +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/query/QueryPlanner.java @@ -0,0 +1,142 @@ +package io.github.randomcodespace.iq.intelligence.query; + +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +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. + */ +@Service +@Profile("serving") +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..ad40fefe 100644 --- a/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java +++ b/src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java @@ -3,7 +3,11 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackAssembler; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackRequest; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; 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; @@ -44,12 +48,16 @@ public class McpTools { private final StatsService statsService; private final TopologyService topologyService; private final GraphStore graphStore; + private final EvidencePackAssembler evidencePackAssembler; + private final ArtifactMetadata artifactMetadata; public McpTools(QueryService queryService, CodeIqConfig config, ObjectMapper objectMapper, Optional flowEngine, GraphDatabaseService graphDb, StatsService statsService, TopologyService topologyService, - GraphStore graphStore) { + GraphStore graphStore, + Optional evidencePackAssembler, + Optional artifactMetadata) { this.queryService = queryService; this.config = config; this.objectMapper = objectMapper; @@ -58,6 +66,8 @@ public McpTools(QueryService queryService, this.statsService = statsService; this.topologyService = topologyService; this.graphStore = graphStore; + this.evidencePackAssembler = evidencePackAssembler.orElse(null); + this.artifactMetadata = artifactMetadata.orElse(null); } /** @@ -337,6 +347,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, @@ -478,6 +508,37 @@ public String findNode( } } + @McpTool(name = "get_evidence_pack", description = "Assemble an evidence pack for a symbol or file. Returns matched nodes, snippets, provenance, and degradation notes. Provide symbol name and/or file path.") + public String getEvidencePack( + @McpToolParam(description = "Symbol name to look up (e.g. UserService, handleLogin)", required = false) String symbol, + @McpToolParam(description = "File path relative to repo root", required = false) String filePath, + @McpToolParam(description = "Max lines per snippet (default: config value)", required = false) Integer maxSnippetLines, + @McpToolParam(description = "Include cross-reference nodes (default: false)", required = false) Boolean includeReferences) { + if (evidencePackAssembler == null) { + return toJson(Map.of("error", "Evidence pack service unavailable. Run 'enrich' first.")); + } + try { + EvidencePackRequest request = new EvidencePackRequest( + symbol, filePath, maxSnippetLines, + Boolean.TRUE.equals(includeReferences)); + return toJson(evidencePackAssembler.assemble(request, artifactMetadata)); + } catch (Exception e) { + return toJson(Map.of("error", e.getMessage())); + } + } + + @McpTool(name = "get_artifact_metadata", description = "Return artifact metadata: repo identity, commit SHA, build timestamp, extractor versions, capability matrix snapshot, and integrity hash.") + public String getArtifactMetadata() { + if (artifactMetadata == null) { + return toJson(Map.of("error", "Artifact metadata unavailable. Run 'enrich' first.")); + } + try { + return toJson(artifactMetadata); + } catch (Exception e) { + return toJson(Map.of("error", e.getMessage())); + } + } + /** * Resolve FlowEngine: use injected instance if available, otherwise create from H2 cache. */ diff --git a/src/main/java/io/github/randomcodespace/iq/model/CodeNode.java b/src/main/java/io/github/randomcodespace/iq/model/CodeNode.java index 1e6dff41..13eca106 100644 --- a/src/main/java/io/github/randomcodespace/iq/model/CodeNode.java +++ b/src/main/java/io/github/randomcodespace/iq/model/CodeNode.java @@ -7,6 +7,8 @@ import org.springframework.data.neo4j.core.schema.Relationship; import org.springframework.data.neo4j.core.schema.Relationship.Direction; +import io.github.randomcodespace.iq.intelligence.Provenance; + import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -169,6 +171,26 @@ public int hashCode() { return Objects.hash(id); } + // --- Provenance helpers --- + + /** + * Write provenance into this node's properties map using {@code prov_*} keys. + * Overwrites any existing provenance. + */ + public void setProvenance(Provenance provenance) { + if (provenance != null) { + properties.putAll(provenance.toProperties()); + } + } + + /** + * Read provenance from this node's properties map. + * Returns null if no provenance keys are present. + */ + public Provenance getProvenance() { + return Provenance.fromProperties(properties); + } + @Override public String toString() { return "CodeNode{id='%s', kind=%s, label='%s'}".formatted(id, kind, label); diff --git a/src/test/java/io/github/randomcodespace/iq/api/IntelligenceControllerTest.java b/src/test/java/io/github/randomcodespace/iq/api/IntelligenceControllerTest.java new file mode 100644 index 00000000..6278fb67 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/api/IntelligenceControllerTest.java @@ -0,0 +1,92 @@ +package io.github.randomcodespace.iq.api; + +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePack; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackAssembler; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackRequest; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class IntelligenceControllerTest { + + private MockMvc mockMvc; + private EvidencePackAssembler assembler; + private ArtifactMetadata metadata; + + @BeforeEach + void setUp() { + assembler = Mockito.mock(EvidencePackAssembler.class); + metadata = new ArtifactMetadata( + "https://github.com/example/repo", "abc123", Instant.now(), + "1", "2", Map.of("code-iq", "1.0"), + Map.of(), "deadbeef"); + + CodeIqConfig config = new CodeIqConfig(); + config.setRootPath(System.getProperty("java.io.tmpdir")); + + IntelligenceController controller = new IntelligenceController(assembler, metadata, config); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void evidenceEndpointReturns200ForValidSymbol() throws Exception { + EvidencePack pack = new EvidencePack( + List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), metadata, CapabilityLevel.EXACT); + when(assembler.assemble(any(EvidencePackRequest.class), any())).thenReturn(pack); + + mockMvc.perform(get("/api/intelligence/evidence").param("symbol", "UserService")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.capabilityLevel").value("EXACT")); + } + + @Test + void evidenceEndpointReturns400WhenNeitherSymbolNorFileProvided() throws Exception { + mockMvc.perform(get("/api/intelligence/evidence")) + .andExpect(status().isBadRequest()); + } + + @Test + void evidenceEndpointReturns400ForPathTraversal() throws Exception { + mockMvc.perform(get("/api/intelligence/evidence") + .param("file", "../../etc/passwd")) + .andExpect(status().isBadRequest()); + } + + @Test + void manifestEndpointReturns200() throws Exception { + mockMvc.perform(get("/api/intelligence/manifest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.commitSha").value("abc123")); + } + + @Test + void capabilitiesEndpointReturnsMatrix() throws Exception { + mockMvc.perform(get("/api/intelligence/capabilities")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.matrix").isMap()); + } + + @Test + void capabilitiesEndpointFiltersbyLanguage() throws Exception { + mockMvc.perform(get("/api/intelligence/capabilities").param("language", "java")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.language").value("java")) + .andExpect(jsonPath("$.capabilities").isMap()); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java b/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java index 14f51a97..d3af11d1 100644 --- a/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java +++ b/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java @@ -76,7 +76,8 @@ void setUp() { mcpTools = new McpTools(queryService, config, objectMapper, Optional.empty(), graphDb, new StatsService(), - new TopologyService(), graphStore); + new TopologyService(), graphStore, + Optional.empty(), Optional.empty()); } private Map buildTopologyResponse() { diff --git a/src/test/java/io/github/randomcodespace/iq/cli/EnrichCommandTest.java b/src/test/java/io/github/randomcodespace/iq/cli/EnrichCommandTest.java index 426bda90..bbfd4447 100644 --- a/src/test/java/io/github/randomcodespace/iq/cli/EnrichCommandTest.java +++ b/src/test/java/io/github/randomcodespace/iq/cli/EnrichCommandTest.java @@ -4,6 +4,7 @@ import io.github.randomcodespace.iq.analyzer.linker.Linker; import io.github.randomcodespace.iq.cache.AnalysisCache; import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalEnricher; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; import io.github.randomcodespace.iq.model.EdgeKind; @@ -50,7 +51,7 @@ void enrichFailsWhenNoIndexExists(@TempDir Path tempDir) { var layerClassifier = new LayerClassifier(); List linkers = List.of(); - var cmd = new EnrichCommand(config, layerClassifier, linkers); + var cmd = new EnrichCommand(config, layerClassifier, linkers, new LexicalEnricher()); var cmdLine = new picocli.CommandLine(cmd); int exitCode = cmdLine.execute(tempDir.toString()); @@ -86,7 +87,7 @@ void enrichWithIndexedData(@TempDir Path tempDir) throws Exception { var layerClassifier = new LayerClassifier(); List linkers = List.of(); - var cmd = new EnrichCommand(config, layerClassifier, linkers); + var cmd = new EnrichCommand(config, layerClassifier, linkers, new LexicalEnricher()); var cmdLine = new picocli.CommandLine(cmd); int exitCode = cmdLine.execute(tempDir.toString()); @@ -121,7 +122,7 @@ void enrichClassifiesLayers(@TempDir Path tempDir) throws Exception { var layerClassifier = new LayerClassifier(); List linkers = List.of(); - var cmd = new EnrichCommand(config, layerClassifier, linkers); + var cmd = new EnrichCommand(config, layerClassifier, linkers, new LexicalEnricher()); var cmdLine = new picocli.CommandLine(cmd); int exitCode = cmdLine.execute(tempDir.toString()); diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/ArtifactManifestTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/ArtifactManifestTest.java new file mode 100644 index 00000000..5961236d --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/ArtifactManifestTest.java @@ -0,0 +1,64 @@ +package io.github.randomcodespace.iq.intelligence; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ArtifactManifestTest { + + @Test + void toMap_bundleFormatIsTwo() { + var manifest = minimalManifest(); + assertThat(manifest.toMap()).containsEntry("bundle_format", ArtifactManifest.BUNDLE_FORMAT_VERSION); + assertThat(ArtifactManifest.BUNDLE_FORMAT_VERSION).isEqualTo(2); + } + + @Test + void toMap_repositoryIdentityIncluded() { + var identity = new RepositoryIdentity( + "https://github.com/example/repo", "abc123def", "main", Instant.now()); + var manifest = new ArtifactManifest(ArtifactManifest.BUNDLE_FORMAT_VERSION, "v1", "myproject", "0.1.0", 1, + Instant.now().toString(), identity, Map.of(), 100L, 200L, true, false, null); + + var map = manifest.toMap(); + assertThat(map).containsKey("repository"); + @SuppressWarnings("unchecked") + var repo = (Map) map.get("repository"); + assertThat(repo).containsEntry("repo_url", "https://github.com/example/repo"); + assertThat(repo).containsEntry("commit_sha", "abc123def"); + assertThat(repo).containsEntry("branch", "main"); + } + + @Test + void toMap_nullRepositoryIdentityOmitted() { + var manifest = new ArtifactManifest(3, null, "proj", "0.1.0", 1, + Instant.now().toString(), null, Map.of(), 0L, 0L, false, false, null); + + assertThat(manifest.toMap()).doesNotContainKey("repository"); + } + + @Test + void toMap_nullTagOmitted() { + var manifest = new ArtifactManifest(3, null, "proj", "0.1.0", 1, + Instant.now().toString(), null, Map.of(), 0L, 0L, false, false, null); + + assertThat(manifest.toMap()).doesNotContainKey("tag"); + } + + @Test + void toMap_checksumsPresentWhenProvided() { + var manifest = new ArtifactManifest(3, "t", "p", "0.1.0", 1, + Instant.now().toString(), null, Map.of(), 10L, 20L, true, false, + Map.of("graph.db.zip", "sha256abc")); + + assertThat(manifest.toMap()).containsKey("checksums"); + } + + private ArtifactManifest minimalManifest() { + return new ArtifactManifest(ArtifactManifest.BUNDLE_FORMAT_VERSION, "latest", "testproject", "0.1.0", 1, + Instant.now().toString(), null, Map.of(), 42L, 84L, true, false, null); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/FileInventoryTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/FileInventoryTest.java new file mode 100644 index 00000000..cc3ec1cd --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/FileInventoryTest.java @@ -0,0 +1,109 @@ +package io.github.randomcodespace.iq.intelligence; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class FileInventoryTest { + + @Test + void entries_areSortedByPath() { + var entries = List.of( + new FileEntry("z/File.java", "java", 100, null, FileClassification.SOURCE), + new FileEntry("a/Main.java", "java", 200, null, FileClassification.SOURCE), + new FileEntry("m/Config.yml", "yaml", 50, null, FileClassification.CONFIG) + ); + var inventory = new FileInventory(entries); + + var paths = inventory.entries().stream().map(FileEntry::path).toList(); + assertThat(paths).containsExactly("a/Main.java", "m/Config.yml", "z/File.java"); + } + + @Test + void entries_areImmutable() { + var inventory = new FileInventory(List.of( + new FileEntry("src/Foo.java", "java", 10, null, FileClassification.SOURCE) + )); + assertThat(inventory.entries()).hasSize(1); + // Attempting to modify should throw + org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class, + () -> inventory.entries().add(new FileEntry("src/Bar.java", "java", 10, null, FileClassification.SOURCE))); + } + + @Test + void countsByClassification_correctCounts() { + var inventory = new FileInventory(List.of( + new FileEntry("src/A.java", "java", 10, null, FileClassification.SOURCE), + new FileEntry("src/B.java", "java", 10, null, FileClassification.SOURCE), + new FileEntry("src/test/ATest.java", "java", 10, null, FileClassification.TEST), + new FileEntry("config.yml", "yaml", 10, null, FileClassification.CONFIG) + )); + + Map counts = inventory.countsByClassification(); + assertThat(counts.get(FileClassification.SOURCE)).isEqualTo(2L); + assertThat(counts.get(FileClassification.TEST)).isEqualTo(1L); + assertThat(counts.get(FileClassification.CONFIG)).isEqualTo(1L); + } + + @Test + void totalBytes_sumsAllFiles() { + var inventory = new FileInventory(List.of( + new FileEntry("a.java", "java", 100, null, FileClassification.SOURCE), + new FileEntry("b.java", "java", 200, null, FileClassification.SOURCE) + )); + assertThat(inventory.totalBytes()).isEqualTo(300L); + } + + @Test + void empty_inventoryConstant() { + assertThat(FileInventory.EMPTY.entries()).isEmpty(); + assertThat(FileInventory.EMPTY.totalFiles()).isZero(); + } + + @Test + void toSummary_containsExpectedKeys() { + var inventory = new FileInventory(List.of( + new FileEntry("src/Main.java", "java", 500, null, FileClassification.SOURCE), + new FileEntry("README.md", "markdown", 100, null, FileClassification.DOC) + )); + var summary = inventory.toSummary(); + + assertThat(summary).containsKey("total_files"); + assertThat(summary).containsKey("total_bytes"); + assertThat(summary).containsKey("by_classification"); + assertThat(summary).containsKey("by_language"); + assertThat(summary.get("total_files")).isEqualTo(2); + assertThat(summary.get("total_bytes")).isEqualTo(600L); + } + + @Test + void fileEntry_classify_testPaths() { + assertThat(FileEntry.classify("src/test/java/FooTest.java", "java")).isEqualTo(FileClassification.TEST); + assertThat(FileEntry.classify("src/main/Foo.java", "java")).isEqualTo(FileClassification.SOURCE); + assertThat(FileEntry.classify("application.yml", "yaml")).isEqualTo(FileClassification.CONFIG); + assertThat(FileEntry.classify("README.md", "markdown")).isEqualTo(FileClassification.DOC); + assertThat(FileEntry.classify("target/generated/Foo.java", "java")).isEqualTo(FileClassification.GENERATED); + } + + @Test + void determinism_sameInputProducesSameOutput() { + var entries1 = List.of( + new FileEntry("c.java", "java", 30, null, FileClassification.SOURCE), + new FileEntry("a.java", "java", 10, null, FileClassification.SOURCE), + new FileEntry("b.java", "java", 20, null, FileClassification.SOURCE) + ); + var entries2 = List.of( + new FileEntry("b.java", "java", 20, null, FileClassification.SOURCE), + new FileEntry("c.java", "java", 30, null, FileClassification.SOURCE), + new FileEntry("a.java", "java", 10, null, FileClassification.SOURCE) + ); + + var inv1 = new FileInventory(entries1); + var inv2 = new FileInventory(entries2); + + assertThat(inv1.entries()).isEqualTo(inv2.entries()); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceEdgeCasesTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceEdgeCasesTest.java new file mode 100644 index 00000000..3c940431 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceEdgeCasesTest.java @@ -0,0 +1,210 @@ +package io.github.randomcodespace.iq.intelligence; + +import io.github.randomcodespace.iq.analyzer.Analyzer; +import io.github.randomcodespace.iq.analyzer.AnalysisResult; +import io.github.randomcodespace.iq.model.CodeNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Edge-case tests for provenance pipeline. + * Covers: empty repos, single-file repos, unsupported-language-only repos, + * mixed-language repos, and repos with no git history. + */ +@SpringBootTest +@ActiveProfiles("indexing") +class ProvenanceEdgeCasesTest { + + @Autowired + Analyzer analyzer; + + // ------------------------------------------------------------------ + // Empty repo — no source files, pipeline should not throw + // ------------------------------------------------------------------ + + @Test + void emptyDirectory_producesNoNodes_noException(@TempDir Path dir) { + assertThatCode(() -> { + AnalysisResult result = analyzer.run(dir, msg -> {}); + assertThat(result).isNotNull(); + // Empty dir may produce zero nodes — that is acceptable + }).doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------ + // Single-file repo — exactly one Java file + // ------------------------------------------------------------------ + + @Test + void singleJavaFile_allNodesHaveProvenance(@TempDir Path dir) throws Exception { + Path src = dir.resolve("Greeter.java"); + Files.writeString(src, """ + public class Greeter { + public String greet(String name) { + return "Hello, " + name; + } + } + """); + + AnalysisResult result = analyzer.run(dir, msg -> {}); + assertThat(result.nodes()).isNotEmpty(); + + for (CodeNode node : result.nodes()) { + assertThat(node.getProvenance()) + .as("Node %s must carry provenance", node.getId()) + .isNotNull(); + assertThat(node.getProvenance().extractorVersion()).isNotBlank(); + assertThat(node.getProvenance().confidence()).isNotNull(); + } + } + + // ------------------------------------------------------------------ + // Unsupported-language-only repo — e.g., only .rb files + // Pipeline should complete gracefully; any nodes produced must have provenance. + // ------------------------------------------------------------------ + + @Test + void unsupportedLanguageOnly_completesGracefully(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("script.rb"), "puts 'hello world'"); + Files.writeString(dir.resolve("helper.brainfuck"), "++++++++[>++++["); + + assertThatCode(() -> { + AnalysisResult result = analyzer.run(dir, msg -> {}); + assertThat(result).isNotNull(); + // Any nodes emitted for unsupported languages must still carry provenance + for (CodeNode node : result.nodes()) { + assertThat(node.getProvenance()) + .as("Node %s from unsupported-language file must have provenance", node.getId()) + .isNotNull(); + } + }).doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------ + // Mixed-language repo — Java + TypeScript + Python + Go + // Every node must carry provenance regardless of language. + // ------------------------------------------------------------------ + + @Test + void mixedLanguageRepo_allNodesHaveProvenance(@TempDir Path dir) throws Exception { + // Java + Path javaDir = dir.resolve("src/main/java/com/example"); + Files.createDirectories(javaDir); + Files.writeString(javaDir.resolve("UserService.java"), """ + package com.example; + public class UserService { + public String findUser(String id) { return id; } + } + """); + + // TypeScript + Path tsDir = dir.resolve("frontend/src"); + Files.createDirectories(tsDir); + Files.writeString(tsDir.resolve("api.ts"), """ + export interface User { id: string; name: string; } + export function fetchUser(id: string): Promise { + return fetch(`/api/users/${id}`).then(r => r.json()); + } + """); + + // Python + Path pyDir = dir.resolve("scripts"); + Files.createDirectories(pyDir); + Files.writeString(pyDir.resolve("process.py"), """ + def process_data(items: list) -> list: + return [item for item in items if item] + """); + + // Go + Path goDir = dir.resolve("cmd"); + Files.createDirectories(goDir); + Files.writeString(goDir.resolve("main.go"), """ + package main + import "fmt" + func main() { + fmt.Println("hello") + } + """); + + AnalysisResult result = analyzer.run(dir, msg -> {}); + List nodes = result.nodes(); + assertThat(nodes).isNotEmpty(); + + for (CodeNode node : nodes) { + assertThat(node.getProvenance()) + .as("Node %s (%s) must carry provenance", node.getId(), node.getFilePath()) + .isNotNull(); + assertThat(node.getProvenance().schemaVersion()) + .as("Node %s must have schemaVersion >= 1", node.getId()) + .isGreaterThanOrEqualTo(1); + } + } + + // ------------------------------------------------------------------ + // No git history — plain directory, not initialised as a git repo. + // commitSha and repoUrl in provenance should be null, not throw. + // ------------------------------------------------------------------ + + @Test + void noGitHistory_provenanceHasNullGitFields(@TempDir Path dir) throws Exception { + Path src = dir.resolve("App.java"); + Files.writeString(src, """ + public class App { + public static void main(String[] args) {} + } + """); + + AnalysisResult result = analyzer.run(dir, msg -> {}); + assertThat(result.nodes()).isNotEmpty(); + + for (CodeNode node : result.nodes()) { + Provenance prov = node.getProvenance(); + assertThat(prov).isNotNull(); + // No git repo → commitSha and repoUrl must be null + assertThat(prov.commitSha()) + .as("No git repo — commitSha should be null for node %s", node.getId()) + .isNull(); + assertThat(prov.repositoryUrl()) + .as("No git repo — repositoryUrl should be null for node %s", node.getId()) + .isNull(); + // extractorVersion and schemaVersion must still be populated + assertThat(prov.extractorVersion()).isNotBlank(); + assertThat(prov.schemaVersion()).isGreaterThanOrEqualTo(1); + } + } + + // ------------------------------------------------------------------ + // Mixed-language determinism — same mixed-language repo analysed twice + // ------------------------------------------------------------------ + + @Test + void mixedLanguageRepo_deterministicProvenance(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("Service.java"), "public class Service {}"); + Files.writeString(dir.resolve("index.ts"), "export const x = 1;"); + Files.writeString(dir.resolve("util.py"), "def helper(): pass"); + + AnalysisResult r1 = analyzer.run(dir, msg -> {}); + AnalysisResult r2 = analyzer.run(dir, msg -> {}); + + assertThat(r1.nodes()).hasSameSizeAs(r2.nodes()); + for (int i = 0; i < r1.nodes().size(); i++) { + Provenance p1 = r1.nodes().get(i).getProvenance(); + Provenance p2 = r2.nodes().get(i).getProvenance(); + assertThat(p1).isNotNull(); + assertThat(p2).isNotNull(); + assertThat(p1.extractorVersion()).isEqualTo(p2.extractorVersion()); + assertThat(p1.schemaVersion()).isEqualTo(p2.schemaVersion()); + assertThat(p1.confidence()).isEqualTo(p2.confidence()); + } + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceIntegrationTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceIntegrationTest.java new file mode 100644 index 00000000..ebeed5b3 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceIntegrationTest.java @@ -0,0 +1,100 @@ +package io.github.randomcodespace.iq.intelligence; + +import io.github.randomcodespace.iq.analyzer.Analyzer; +import io.github.randomcodespace.iq.analyzer.AnalysisResult; +import io.github.randomcodespace.iq.model.CodeNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that every node produced by the analysis pipeline carries provenance. + * Also validates determinism: running twice on the same input produces identical provenance. + */ +@SpringBootTest +@ActiveProfiles("indexing") +class ProvenanceIntegrationTest { + + @Autowired + Analyzer analyzer; + + @Test + void everyNodeHasProvenance(@TempDir Path tempDir) throws Exception { + // Write a minimal Java source file + Path src = tempDir.resolve("src/main/java/com/example/HelloController.java"); + Files.createDirectories(src.getParent()); + Files.writeString(src, """ + package com.example; + import org.springframework.web.bind.annotation.RestController; + import org.springframework.web.bind.annotation.GetMapping; + @RestController + public class HelloController { + @GetMapping("/hello") + public String hello() { return "hello"; } + } + """); + + AnalysisResult result = analyzer.run(tempDir, msg -> {}); + + List nodes = result.nodes(); + assertThat(nodes).isNotNull(); + assertThat(nodes).isNotEmpty(); + + for (CodeNode node : nodes) { + Provenance prov = node.getProvenance(); + assertThat(prov) + .as("Node %s should have provenance", node.getId()) + .isNotNull(); + assertThat(prov.extractorVersion()) + .as("Node %s should have extractorVersion", node.getId()) + .isNotBlank(); + assertThat(prov.confidence()) + .as("Node %s should have confidence", node.getId()) + .isNotNull(); + assertThat(prov.schemaVersion()) + .as("Node %s should have schemaVersion >= 1", node.getId()) + .isGreaterThanOrEqualTo(1); + } + } + + @Test + void provenance_isDeterministic(@TempDir Path tempDir) throws Exception { + Path src = tempDir.resolve("src/Foo.java"); + Files.createDirectories(src.getParent()); + Files.writeString(src, """ + package com; + public class Foo { + public void bar() {} + } + """); + + // Run twice + AnalysisResult r1 = analyzer.run(tempDir, msg -> {}); + AnalysisResult r2 = analyzer.run(tempDir, msg -> {}); + + assertThat(r1.nodes()).isNotNull(); + assertThat(r2.nodes()).isNotNull(); + + // Same node count + assertThat(r1.nodes()).hasSameSizeAs(r2.nodes()); + + // Provenance fields must be identical (same extractor version, same schema version) + for (int i = 0; i < r1.nodes().size(); i++) { + Provenance p1 = r1.nodes().get(i).getProvenance(); + Provenance p2 = r2.nodes().get(i).getProvenance(); + assertThat(p1).isNotNull(); + assertThat(p2).isNotNull(); + assertThat(p1.extractorVersion()).isEqualTo(p2.extractorVersion()); + assertThat(p1.schemaVersion()).isEqualTo(p2.schemaVersion()); + assertThat(p1.confidence()).isEqualTo(p2.confidence()); + } + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceNeo4jRoundTripTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceNeo4jRoundTripTest.java new file mode 100644 index 00000000..748133ca --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceNeo4jRoundTripTest.java @@ -0,0 +1,155 @@ +package io.github.randomcodespace.iq.intelligence; + +import io.github.randomcodespace.iq.graph.GraphRepository; +import io.github.randomcodespace.iq.graph.GraphStore; +import io.github.randomcodespace.iq.model.CodeNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.graphdb.GraphDatabaseService; +import org.neo4j.graphdb.Result; +import org.neo4j.graphdb.Transaction; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies the {@code prov_*} → {@code prop_prov_*} → {@code prov_*} Neo4j round-trip. + *

+ * {@link io.github.randomcodespace.iq.graph.GraphStore#bulkSave} stores node properties + * as {@code prop_} in Neo4j (all values coerced to String via {@code toString()}). + * {@code nodeFromNeo4j()} restores them by stripping the {@code prop_} prefix. + * This test verifies that provenance fields survive that transformation, including + * the {@code schemaVersion} Integer→String→int coercion. + */ +@ExtendWith(MockitoExtension.class) +class ProvenanceNeo4jRoundTripTest { + + @Mock + private GraphRepository repository; + + @Mock + private GraphDatabaseService graphDb; + + private GraphStore store; + + @BeforeEach + void setUp() { + store = new GraphStore(repository, graphDb); + } + + @Test + void provenance_survivesNeo4jRoundTrip() { + // Arrange: mock a Neo4j node with prop_prov_* keys (what bulkSave writes). + // bulkSave stores all properties as Strings via .toString(), so schemaVersion + // is stored as "1" not 1. + var neo4jNode = mock(org.neo4j.graphdb.Node.class); + when(neo4jNode.getProperty("id", null)).thenReturn("prov:roundtrip:test"); + when(neo4jNode.getProperty("kind", null)).thenReturn("endpoint"); + when(neo4jNode.getProperty("label", "")).thenReturn("TestEndpoint"); + when(neo4jNode.getProperty("fqn", null)).thenReturn(null); + when(neo4jNode.getProperty("module", null)).thenReturn(null); + when(neo4jNode.getProperty("filePath", null)).thenReturn(null); + when(neo4jNode.getProperty("layer", null)).thenReturn(null); + when(neo4jNode.getProperty("lineStart", null)).thenReturn(null); + when(neo4jNode.getProperty("lineEnd", null)).thenReturn(null); + when(neo4jNode.getProperty("annotations", null)).thenReturn(null); + + // Property keys as stored by bulkSave (prop_ prefix, values as String) + when(neo4jNode.getPropertyKeys()).thenReturn(List.of( + "prop_prov_repo_url", + "prop_prov_commit_sha", + "prop_prov_extractor_version", + "prop_prov_schema_version", + "prop_prov_confidence" + )); + when(neo4jNode.getProperty("prop_prov_repo_url")).thenReturn("https://github.com/example/repo"); + when(neo4jNode.getProperty("prop_prov_commit_sha")).thenReturn("abc123def456"); + when(neo4jNode.getProperty("prop_prov_extractor_version")).thenReturn("0.1.0-SNAPSHOT"); + when(neo4jNode.getProperty("prop_prov_schema_version")).thenReturn("1"); // String after bulkSave + when(neo4jNode.getProperty("prop_prov_confidence")).thenReturn("PARTIAL"); + + // Mock Transaction: first execute() returns the node, second (edges) returns empty + var tx = mock(Transaction.class); + when(graphDb.beginTx()).thenReturn(tx); + + var nodeResult = mock(Result.class); + when(nodeResult.hasNext()).thenReturn(true, false); + when(nodeResult.next()).thenReturn(Map.of("n", neo4jNode)); + + var edgeResult = mock(Result.class); + when(edgeResult.hasNext()).thenReturn(false); + + when(tx.execute(anyString(), anyMap())).thenReturn(nodeResult, edgeResult); + + // Act: findById invokes nodeFromNeo4j() internally + Optional result = store.findById("prov:roundtrip:test"); + + // Assert: node is present and provenance is fully restored + assertThat(result).isPresent(); + CodeNode node = result.get(); + assertThat(node.getId()).isEqualTo("prov:roundtrip:test"); + + Provenance prov = node.getProvenance(); + assertThat(prov).as("Provenance must be restored after Neo4j round-trip").isNotNull(); + assertThat(prov.repositoryUrl()).isEqualTo("https://github.com/example/repo"); + assertThat(prov.commitSha()).isEqualTo("abc123def456"); + assertThat(prov.extractorVersion()).isEqualTo("0.1.0-SNAPSHOT"); + assertThat(prov.schemaVersion()).isEqualTo(1); + assertThat(prov.confidence()).isEqualTo(CapabilityLevel.PARTIAL); + } + + @Test + void provenance_survivesNeo4jRoundTrip_withNullRepoUrl() { + // Verifies that absent optional fields (repo_url, commit_sha) round-trip as null + var neo4jNode = mock(org.neo4j.graphdb.Node.class); + when(neo4jNode.getProperty("id", null)).thenReturn("prov:roundtrip:nullfields"); + when(neo4jNode.getProperty("kind", null)).thenReturn("class"); + when(neo4jNode.getProperty("label", "")).thenReturn("SomeClass"); + when(neo4jNode.getProperty("fqn", null)).thenReturn(null); + when(neo4jNode.getProperty("module", null)).thenReturn(null); + when(neo4jNode.getProperty("filePath", null)).thenReturn(null); + when(neo4jNode.getProperty("layer", null)).thenReturn(null); + when(neo4jNode.getProperty("lineStart", null)).thenReturn(null); + when(neo4jNode.getProperty("lineEnd", null)).thenReturn(null); + when(neo4jNode.getProperty("annotations", null)).thenReturn(null); + + // Only required provenance keys (no repo_url, no commit_sha) + when(neo4jNode.getPropertyKeys()).thenReturn(List.of( + "prop_prov_extractor_version", + "prop_prov_schema_version", + "prop_prov_confidence" + )); + when(neo4jNode.getProperty("prop_prov_extractor_version")).thenReturn("0.1.0-SNAPSHOT"); + when(neo4jNode.getProperty("prop_prov_schema_version")).thenReturn("1"); + when(neo4jNode.getProperty("prop_prov_confidence")).thenReturn("EXACT"); + + var tx = mock(Transaction.class); + when(graphDb.beginTx()).thenReturn(tx); + var nodeResult = mock(Result.class); + when(nodeResult.hasNext()).thenReturn(true, false); + when(nodeResult.next()).thenReturn(Map.of("n", neo4jNode)); + var edgeResult = mock(Result.class); + when(edgeResult.hasNext()).thenReturn(false); + when(tx.execute(anyString(), anyMap())).thenReturn(nodeResult, edgeResult); + + Optional result = store.findById("prov:roundtrip:nullfields"); + + assertThat(result).isPresent(); + Provenance prov = result.get().getProvenance(); + assertThat(prov).isNotNull(); + assertThat(prov.repositoryUrl()).isNull(); + assertThat(prov.commitSha()).isNull(); + assertThat(prov.confidence()).isEqualTo(CapabilityLevel.EXACT); + assertThat(prov.schemaVersion()).isEqualTo(1); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceTest.java new file mode 100644 index 00000000..0e0bb6d8 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/ProvenanceTest.java @@ -0,0 +1,81 @@ +package io.github.randomcodespace.iq.intelligence; + +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProvenanceTest { + + @Test + void toProperties_populatesAllKeys() { + var p = new Provenance("https://github.com/example/repo", "abc123", "0.1.0", 1, CapabilityLevel.PARTIAL); + var props = p.toProperties(); + + assertThat(props).containsEntry(Provenance.KEY_REPO_URL, "https://github.com/example/repo"); + assertThat(props).containsEntry(Provenance.KEY_COMMIT_SHA, "abc123"); + assertThat(props).containsEntry(Provenance.KEY_EXTRACTOR_VER, "0.1.0"); + assertThat(props).containsEntry(Provenance.KEY_SCHEMA_VER, 1); + assertThat(props).containsEntry(Provenance.KEY_CONFIDENCE, "PARTIAL"); + } + + @Test + void toProperties_skipsNullRepoUrl() { + var p = new Provenance(null, null, "0.1.0", 1, CapabilityLevel.EXACT); + var props = p.toProperties(); + + assertThat(props).doesNotContainKey(Provenance.KEY_REPO_URL); + assertThat(props).doesNotContainKey(Provenance.KEY_COMMIT_SHA); + assertThat(props).containsKey(Provenance.KEY_EXTRACTOR_VER); + } + + @Test + void fromProperties_roundTrip() { + var original = new Provenance("https://github.com/x/y", "sha999", "1.0", 1, CapabilityLevel.EXACT); + var props = original.toProperties(); + var restored = Provenance.fromProperties(props); + + assertThat(restored).isNotNull(); + assertThat(restored.repositoryUrl()).isEqualTo("https://github.com/x/y"); + assertThat(restored.commitSha()).isEqualTo("sha999"); + assertThat(restored.extractorVersion()).isEqualTo("1.0"); + assertThat(restored.schemaVersion()).isEqualTo(1); + assertThat(restored.confidence()).isEqualTo(CapabilityLevel.EXACT); + } + + @Test + void fromProperties_returnsNullForEmptyMap() { + assertThat(Provenance.fromProperties(java.util.Map.of())).isNull(); + assertThat(Provenance.fromProperties(null)).isNull(); + } + + @Test + void codeNode_setAndGetProvenance() { + var node = new CodeNode("id1", NodeKind.ENDPOINT, "MyEndpoint"); + var p = new Provenance("https://github.com/a/b", "deadbeef", "0.1.0", 1, CapabilityLevel.PARTIAL); + + node.setProvenance(p); + + assertThat(node.getProperties()).containsKey(Provenance.KEY_EXTRACTOR_VER); + assertThat(node.getProperties()).containsEntry(Provenance.KEY_COMMIT_SHA, "deadbeef"); + + Provenance restored = node.getProvenance(); + assertThat(restored).isNotNull(); + assertThat(restored.commitSha()).isEqualTo("deadbeef"); + assertThat(restored.confidence()).isEqualTo(CapabilityLevel.PARTIAL); + } + + @Test + void codeNode_setProvenance_isIdempotent() { + var node = new CodeNode("id2", NodeKind.ENDPOINT, "EP"); + var p1 = new Provenance(null, "sha1", "0.1.0", 1, CapabilityLevel.PARTIAL); + var p2 = new Provenance(null, "sha2", "0.1.0", 1, CapabilityLevel.EXACT); + + node.setProvenance(p1); + node.setProvenance(p2); + + assertThat(node.getProvenance().commitSha()).isEqualTo("sha2"); + assertThat(node.getProvenance().confidence()).isEqualTo(CapabilityLevel.EXACT); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentityTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentityTest.java new file mode 100644 index 00000000..1718a4dd --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentityTest.java @@ -0,0 +1,139 @@ +package io.github.randomcodespace.iq.intelligence; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Unit tests for {@link RepositoryIdentity}. + * Validates graceful degradation when git metadata is unavailable. + */ +class RepositoryIdentityTest { + + // ------------------------------------------------------------------ + // Non-git directory — all git fields null, no exception + // ------------------------------------------------------------------ + + @Test + void resolve_nonGitDirectory_allNullNoException(@TempDir Path dir) { + assertThatCode(() -> RepositoryIdentity.resolve(dir)).doesNotThrowAnyException(); + + RepositoryIdentity id = RepositoryIdentity.resolve(dir); + assertThat(id.repoUrl()).isNull(); + assertThat(id.commitSha()).isNull(); + assertThat(id.branch()).isNull(); + } + + @Test + void resolve_nonGitDirectory_buildTimestampAlwaysPresent(@TempDir Path dir) { + RepositoryIdentity id = RepositoryIdentity.resolve(dir); + assertThat(id.buildTimestamp()).isNotNull(); + } + + // ------------------------------------------------------------------ + // Git repo with no remote — commitSha present, repoUrl null + // ------------------------------------------------------------------ + + @Test + void resolve_gitRepoNoRemote_commitShaPopulated(@TempDir Path dir) throws Exception { + initGitRepo(dir); + + RepositoryIdentity id = RepositoryIdentity.resolve(dir); + // commit SHA may be null if no commits yet, but should not throw + assertThat(id.repoUrl()).isNull(); + assertThat(id.buildTimestamp()).isNotNull(); + } + + @Test + void resolve_gitRepoWithCommit_commitShaPresent(@TempDir Path dir) throws Exception { + initGitRepo(dir); + makeInitialCommit(dir); + + RepositoryIdentity id = RepositoryIdentity.resolve(dir); + assertThat(id.commitSha()).isNotNull().isNotBlank(); + assertThat(id.repoUrl()).isNull(); + } + + // ------------------------------------------------------------------ + // Detached HEAD — branch normalised to null + // ------------------------------------------------------------------ + + @Test + void resolve_detachedHead_branchIsNull(@TempDir Path dir) throws Exception { + initGitRepo(dir); + makeInitialCommit(dir); + + // Detach HEAD by checking out the commit SHA directly + String sha = runGit(dir, "rev-parse", "HEAD"); + runGit(dir, "checkout", "--detach", sha); + + RepositoryIdentity id = RepositoryIdentity.resolve(dir); + assertThat(id.branch()).isNull(); + assertThat(id.commitSha()).isNotNull(); + } + + // ------------------------------------------------------------------ + // Record semantics + // ------------------------------------------------------------------ + + @Test + void record_constructorAndAccessors() { + var ts = java.time.Instant.now(); + var id = new RepositoryIdentity("https://github.com/x/y", "abc123", "main", ts); + + assertThat(id.repoUrl()).isEqualTo("https://github.com/x/y"); + assertThat(id.commitSha()).isEqualTo("abc123"); + assertThat(id.branch()).isEqualTo("main"); + assertThat(id.buildTimestamp()).isEqualTo(ts); + } + + @Test + void record_equalityOnSameValues() { + var ts = java.time.Instant.parse("2026-01-01T00:00:00Z"); + var id1 = new RepositoryIdentity("url", "sha", "main", ts); + var id2 = new RepositoryIdentity("url", "sha", "main", ts); + assertThat(id1).isEqualTo(id2); + } + + @Test + void record_nullFieldsAllowed() { + assertThatCode(() -> new RepositoryIdentity(null, null, null, java.time.Instant.now())) + .doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static void initGitRepo(Path dir) throws Exception { + run(dir, "git", "init"); + run(dir, "git", "config", "user.email", "test@test.com"); + run(dir, "git", "config", "user.name", "Test"); + } + + private static void makeInitialCommit(Path dir) throws Exception { + Path readme = dir.resolve("README.md"); + java.nio.file.Files.writeString(readme, "# Test"); + run(dir, "git", "add", "."); + run(dir, "git", "commit", "-m", "init"); + } + + private static String runGit(Path dir, String... args) throws Exception { + var cmd = new java.util.ArrayList(); + cmd.add("git"); + cmd.addAll(java.util.Arrays.asList(args)); + var proc = new ProcessBuilder(cmd).directory(dir.toFile()).start(); + String out = new String(proc.getInputStream().readAllBytes()).trim(); + proc.waitFor(); + return out; + } + + private static void run(Path dir, String... cmd) throws Exception { + new ProcessBuilder(cmd).directory(dir.toFile()) + .redirectErrorStream(true).start().waitFor(); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssemblerTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssemblerTest.java new file mode 100644 index 00000000..fa314acb --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/evidence/EvidencePackAssemblerTest.java @@ -0,0 +1,126 @@ +package io.github.randomcodespace.iq.intelligence.evidence; + +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.graph.GraphStore; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalQueryService; +import io.github.randomcodespace.iq.intelligence.lexical.LexicalResult; +import io.github.randomcodespace.iq.intelligence.lexical.SnippetStore; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import io.github.randomcodespace.iq.intelligence.query.QueryPlanner; +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class EvidencePackAssemblerTest { + + @Mock + private LexicalQueryService lexicalQueryService; + @Mock + private SnippetStore snippetStore; + @Mock + private GraphStore graphStore; + + private QueryPlanner queryPlanner; + private CodeIqConfig config; + private EvidencePackAssembler assembler; + private ArtifactMetadata metadata; + + @BeforeEach + void setUp() { + queryPlanner = new QueryPlanner(); + config = new CodeIqConfig(); + config.setRootPath(System.getProperty("java.io.tmpdir")); + config.setMaxSnippetLines(50); + assembler = new EvidencePackAssembler(lexicalQueryService, snippetStore, queryPlanner, config, graphStore); + metadata = new ArtifactMetadata( + "https://github.com/example/repo", "abc123", Instant.now(), + "1", "2", Map.of("code-iq", "1.0"), + Map.of(), "deadbeef"); + } + + @Test + void assemblesPackForKnownSymbol() { + CodeNode node = new CodeNode("java:Foo.java:class:Foo", NodeKind.CLASS, "Foo"); + node.setFilePath("src/Foo.java"); + node.setLineStart(1); + node.setLineEnd(10); + + when(lexicalQueryService.findByIdentifier("Foo")).thenReturn( + List.of(LexicalResult.of(node, 1.0f, "identifier"))); + when(snippetStore.extract(any(CodeNode.class), any())).thenReturn(java.util.Optional.empty()); + + // Provide filePath so language resolves to "java" → GRAPH_FIRST route → no degradation note + EvidencePackRequest req = new EvidencePackRequest("Foo", "src/Foo.java", null, false); + EvidencePack pack = assembler.assemble(req, metadata); + + assertThat(pack.matchedSymbols()).hasSize(1); + assertThat(pack.matchedSymbols().get(0).getLabel()).isEqualTo("Foo"); + assertThat(pack.relatedFiles()).contains("src/Foo.java"); + assertThat(pack.degradationNotes()).isEmpty(); + assertThat(pack.capabilityLevel()).isNotNull(); + } + + @Test + void returnsEmptyPackWithDegradationNoteForMissingSymbol() { + when(lexicalQueryService.findByIdentifier(anyString())).thenReturn(List.of()); + + EvidencePackRequest req = new EvidencePackRequest("NonExistent", null, null, false); + EvidencePack pack = assembler.assemble(req, metadata); + + assertThat(pack.matchedSymbols()).isEmpty(); + assertThat(pack.snippets()).isEmpty(); + assertThat(pack.degradationNotes()).isNotEmpty(); + assertThat(pack.capabilityLevel()).isEqualTo(CapabilityLevel.UNSUPPORTED); + } + + @Test + void returnsEmptyPackWhenNeitherSymbolNorFileProvided() { + EvidencePackRequest req = new EvidencePackRequest(null, null, null, false); + EvidencePack pack = assembler.assemble(req, metadata); + + assertThat(pack.matchedSymbols()).isEmpty(); + assertThat(pack.degradationNotes()).isNotEmpty(); + } + + @Test + void isDeterministic() { + CodeNode node = new CodeNode("java:Bar.java:class:Bar", NodeKind.CLASS, "Bar"); + node.setFilePath("src/Bar.java"); + node.setLineStart(1); + node.setLineEnd(5); + + when(lexicalQueryService.findByIdentifier("Bar")).thenReturn( + List.of(LexicalResult.of(node, 1.0f, "identifier"))); + when(snippetStore.extract(any(CodeNode.class), any())).thenReturn(java.util.Optional.empty()); + + EvidencePackRequest req = new EvidencePackRequest("Bar", null, null, false); + EvidencePack pack1 = assembler.assemble(req, metadata); + EvidencePack pack2 = assembler.assemble(req, metadata); + + assertThat(pack1.matchedSymbols().stream().map(CodeNode::getId).toList()) + .isEqualTo(pack2.matchedSymbols().stream().map(CodeNode::getId).toList()); + assertThat(pack1.relatedFiles()).isEqualTo(pack2.relatedFiles()); + assertThat(pack1.capabilityLevel()).isEqualTo(pack2.capabilityLevel()); + } + + @Test + void respectsMaxSnippetLinesFromConfig() { + config.setMaxSnippetLines(10); + assertThat(config.getMaxSnippetLines()).isEqualTo(10); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractorTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractorTest.java new file mode 100644 index 00000000..de57ed3f --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/DocCommentExtractorTest.java @@ -0,0 +1,125 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class DocCommentExtractorTest { + + @TempDir + Path tmp; + + // --- Javadoc block comments --- + + @Test + void extractsJavadocBeforeClass() throws Exception { + Path file = tmp.resolve("Foo.java"); + Files.writeString(file, """ + /** + * This is the Foo class. + * It does foo things. + */ + public class Foo {} + """); + String result = DocCommentExtractor.extract(file, "java", 5); + assertThat(result).contains("Foo class").contains("foo things"); + } + + @Test + void extractsJavadocSkippingAnnotations() throws Exception { + Path file = tmp.resolve("Bar.java"); + Files.writeString(file, """ + /** + * Bar service implementation. + */ + @Service + @Transactional + public class Bar {} + """); + String result = DocCommentExtractor.extract(file, "java", 6); + assertThat(result).contains("Bar service implementation"); + } + + @Test + void returnsNullWhenNoDocComment() throws Exception { + Path file = tmp.resolve("Plain.java"); + Files.writeString(file, """ + public class Plain { + void method() {} + } + """); + String result = DocCommentExtractor.extract(file, "java", 1); + assertThat(result).isNull(); + } + + @Test + void returnsNullForNullArgs() { + assertThat(DocCommentExtractor.extract(null, "java", 1)).isNull(); + assertThat(DocCommentExtractor.extract(tmp.resolve("x.java"), null, 1)).isNull(); + assertThat(DocCommentExtractor.extract(tmp.resolve("x.java"), "java", 0)).isNull(); + } + + // --- Python docstrings --- + + @Test + void extractsPythonDoubleQuoteDocstring() throws Exception { + Path file = tmp.resolve("service.py"); + Files.writeString(file, """ + def handle_request(req): + \"\"\"Handle an incoming HTTP request.\"\"\" + pass + """); + String result = DocCommentExtractor.extract(file, "python", 1); + assertThat(result).contains("Handle an incoming HTTP request"); + } + + @Test + void extractsPythonMultilineDocstring() throws Exception { + Path file = tmp.resolve("multi.py"); + Files.writeString(file, """ + class UserService: + \"\"\" + Service for managing users. + Supports CRUD operations. + \"\"\" + pass + """); + String result = DocCommentExtractor.extract(file, "python", 1); + assertThat(result).contains("Service for managing users"); + } + + // --- Go line comments --- + + @Test + void extractsGoLineComments() throws Exception { + Path file = tmp.resolve("handler.go"); + Files.writeString(file, """ + // HandleLogin processes a user login request. + // Returns 401 on failure. + func HandleLogin(w http.ResponseWriter, r *http.Request) { + } + """); + String result = DocCommentExtractor.extract(file, "go", 3); + assertThat(result).contains("HandleLogin").contains("401"); + } + + // --- Determinism --- + + @Test + void extractionIsDeterministic() throws Exception { + Path file = tmp.resolve("Det.java"); + Files.writeString(file, """ + /** + * Deterministic class. + */ + public class Det {} + """); + String r1 = DocCommentExtractor.extract(file, "java", 4); + String r2 = DocCommentExtractor.extract(file, "java", 4); + assertThat(r1).isEqualTo(r2); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalCrossLanguageTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalCrossLanguageTest.java new file mode 100644 index 00000000..c9588252 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalCrossLanguageTest.java @@ -0,0 +1,272 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Cross-language lexical enrichment tests. + * Validates DocCommentExtractor and LexicalEnricher for TypeScript, Python, Go, and JavaScript. + * Complements {@link LexicalEnricherTest} which only covers Java. + */ +class LexicalCrossLanguageTest { + + @TempDir + Path root; + + private final LexicalEnricher enricher = new LexicalEnricher(); + + // ------------------------------------------------------------------ + // TypeScript — block comment (/** ... */) + // ------------------------------------------------------------------ + + @Test + void typescript_blockComment_extracted() throws Exception { + Path file = root.resolve("UserService.ts"); + Files.writeString(file, """ + /** + * Fetches a user by their unique identifier. + */ + export class UserService { + fetchUser(id: string) { return id; } + } + """); + + CodeNode node = new CodeNode("ts:id1", NodeKind.CLASS, "UserService"); + node.setFilePath("UserService.ts"); + node.setLineStart(4); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString()) + .contains("unique identifier"); + } + + @Test + void typescript_noComment_noLexKey() throws Exception { + Path file = root.resolve("Bare.ts"); + Files.writeString(file, """ + export function bare() { return 42; } + """); + + CodeNode node = new CodeNode("ts:id2", NodeKind.METHOD, "bare"); + node.setFilePath("Bare.ts"); + node.setLineStart(1); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).doesNotContainKey(LexicalEnricher.KEY_LEX_COMMENT); + } + + // ------------------------------------------------------------------ + // JavaScript — block comment (/** ... */) + // ------------------------------------------------------------------ + + @Test + void javascript_jsDocComment_extracted() throws Exception { + Path file = root.resolve("helper.js"); + Files.writeString(file, """ + /** + * Computes the sum of two numbers. + * @param {number} a + * @param {number} b + */ + function add(a, b) { + return a + b; + } + """); + + CodeNode node = new CodeNode("js:id1", NodeKind.METHOD, "add"); + node.setFilePath("helper.js"); + node.setLineStart(6); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString()) + .contains("sum"); + } + + // ------------------------------------------------------------------ + // Python — triple-quoted docstring + // ------------------------------------------------------------------ + + @Test + void python_tripleDoubleQuotedDocstring_extracted() throws Exception { + Path file = root.resolve("processor.py"); + Files.writeString(file, """ + class DataProcessor: + \"\"\"Processes raw data into structured records.\"\"\" + def run(self): pass + """); + + CodeNode node = new CodeNode("py:id1", NodeKind.CLASS, "DataProcessor"); + node.setFilePath("processor.py"); + node.setLineStart(1); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString()) + .contains("structured records"); + } + + @Test + void python_multilineDocstring_extractedTrimmed() throws Exception { + Path file = root.resolve("service.py"); + Files.writeString(file, """ + def fetch_user(user_id: str): + \"\"\" + Fetch a user from the database. + Returns None if not found. + \"\"\" + return None + """); + + CodeNode node = new CodeNode("py:id2", NodeKind.METHOD, "fetch_user"); + node.setFilePath("service.py"); + node.setLineStart(1); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + String comment = node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString(); + assertThat(comment).contains("database"); + assertThat(comment).contains("None if not found"); + } + + // ------------------------------------------------------------------ + // Go — line comments (//) + // ------------------------------------------------------------------ + + @Test + void go_lineComments_extracted() throws Exception { + Path file = root.resolve("handler.go"); + Files.writeString(file, """ + package handler + + // ServeHTTP handles incoming HTTP requests. + // It validates the token and returns 401 if invalid. + func ServeHTTP(w http.ResponseWriter, r *http.Request) { + } + """); + + CodeNode node = new CodeNode("go:id1", NodeKind.METHOD, "ServeHTTP"); + node.setFilePath("handler.go"); + node.setLineStart(5); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString()) + .contains("HTTP requests"); + } + + @Test + void go_noComment_noLexKey() throws Exception { + Path file = root.resolve("bare.go"); + Files.writeString(file, """ + package bare + func noop() {} + """); + + CodeNode node = new CodeNode("go:id2", NodeKind.METHOD, "noop"); + node.setFilePath("bare.go"); + node.setLineStart(2); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).doesNotContainKey(LexicalEnricher.KEY_LEX_COMMENT); + } + + // ------------------------------------------------------------------ + // Determinism — same cross-language nodes enriched twice yield same result + // ------------------------------------------------------------------ + + @Test + void crossLanguage_deterministicEnrichment() throws Exception { + Path tsFile = root.resolve("api.ts"); + Files.writeString(tsFile, """ + /** Returns the current user session. */ + export function getSession() {} + """); + + Path pyFile = root.resolve("models.py"); + Files.writeString(pyFile, """ + class Order: + \"\"\"Represents a customer order.\"\"\" + pass + """); + + CodeNode ts1 = new CodeNode("ts:d1", NodeKind.METHOD, "getSession"); + ts1.setFilePath("api.ts"); ts1.setLineStart(2); + + CodeNode ts2 = new CodeNode("ts:d2", NodeKind.METHOD, "getSession"); + ts2.setFilePath("api.ts"); ts2.setLineStart(2); + + CodeNode py1 = new CodeNode("py:d1", NodeKind.CLASS, "Order"); + py1.setFilePath("models.py"); py1.setLineStart(1); + + CodeNode py2 = new CodeNode("py:d2", NodeKind.CLASS, "Order"); + py2.setFilePath("models.py"); py2.setLineStart(1); + + enricher.enrich(List.of(ts1, py1), root); + enricher.enrich(List.of(ts2, py2), root); + + assertThat(ts1.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)) + .isEqualTo(ts2.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)); + assertThat(py1.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)) + .isEqualTo(py2.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)); + } + + // ------------------------------------------------------------------ + // DocCommentExtractor direct — Go and Python extraction + // ------------------------------------------------------------------ + + @Test + void docCommentExtractor_python_singleLineTripledQuote() throws Exception { + Path file = root.resolve("util.py"); + Files.writeString(file, """ + def compute(): + \"\"\"Computes the result.\"\"\" + return 42 + """); + + String comment = DocCommentExtractor.extract(file, "python", 1); + assertThat(comment).contains("Computes the result"); + } + + @Test + void docCommentExtractor_go_lineComment() throws Exception { + Path file = root.resolve("repo.go"); + Files.writeString(file, """ + // FindAll retrieves all records from the store. + func FindAll() {} + """); + + String comment = DocCommentExtractor.extract(file, "go", 2); + assertThat(comment).contains("retrieves all records"); + } + + @Test + void docCommentExtractor_typescript_blockComment() throws Exception { + Path file = root.resolve("client.ts"); + Files.writeString(file, """ + /** + * HTTP client for the backend API. + */ + export class ApiClient {} + """); + + String comment = DocCommentExtractor.extract(file, "typescript", 4); + assertThat(comment).contains("backend API"); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricherTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricherTest.java new file mode 100644 index 00000000..3f0d1bba --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/LexicalEnricherTest.java @@ -0,0 +1,110 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class LexicalEnricherTest { + + @TempDir + Path root; + + private final LexicalEnricher enricher = new LexicalEnricher(); + + @Test + void enrichesDocCommentForClass() throws Exception { + Path file = root.resolve("MyService.java"); + Files.writeString(file, """ + /** + * Handles user authentication. + */ + public class MyService {} + """); + + CodeNode node = new CodeNode("id1", NodeKind.CLASS, "MyService"); + node.setFilePath("MyService.java"); + node.setLineStart(4); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_COMMENT); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT).toString()) + .contains("user authentication"); + } + + @Test + void enrichesConfigKeyForConfigNode() { + CodeNode node = new CodeNode("id2", NodeKind.CONFIG_KEY, "spring.datasource.url"); + node.setFqn("spring.datasource.url"); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_CONFIG_KEYS); + assertThat(node.getProperties().get(LexicalEnricher.KEY_LEX_CONFIG_KEYS).toString()) + .isEqualTo("spring.datasource.url"); + } + + @Test + void enrichesConfigFileNode() { + CodeNode node = new CodeNode("id3", NodeKind.CONFIG_FILE, "application.yml"); + node.setLabel("application.yml"); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).containsKey(LexicalEnricher.KEY_LEX_CONFIG_KEYS); + } + + @Test + void skipsNodesWithoutFilePath() { + CodeNode node = new CodeNode("id4", NodeKind.CLASS, "Bare"); + // no filePath, no lineStart + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).doesNotContainKey(LexicalEnricher.KEY_LEX_COMMENT); + } + + @Test + void doesNotEnrichModuleOrTopicNodes() throws Exception { + Path file = root.resolve("module.java"); + Files.writeString(file, "/** doc */\nmodule foo {}"); + + CodeNode node = new CodeNode("id5", NodeKind.MODULE, "foo"); + node.setFilePath("module.java"); + node.setLineStart(2); + + enricher.enrich(List.of(node), root); + + assertThat(node.getProperties()).doesNotContainKey(LexicalEnricher.KEY_LEX_COMMENT); + } + + @Test + void enrichmentIsDeterministic() throws Exception { + Path file = root.resolve("Svc.java"); + Files.writeString(file, """ + /** Service docs. */ + public class Svc {} + """); + + CodeNode n1 = new CodeNode("id6", NodeKind.CLASS, "Svc"); + n1.setFilePath("Svc.java"); + n1.setLineStart(2); + + CodeNode n2 = new CodeNode("id7", NodeKind.CLASS, "Svc"); + n2.setFilePath("Svc.java"); + n2.setLineStart(2); + + enricher.enrich(List.of(n1), root); + enricher.enrich(List.of(n2), root); + + assertThat(n1.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)) + .isEqualTo(n2.getProperties().get(LexicalEnricher.KEY_LEX_COMMENT)); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStoreTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStoreTest.java new file mode 100644 index 00000000..12f5fdf7 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/lexical/SnippetStoreTest.java @@ -0,0 +1,118 @@ +package io.github.randomcodespace.iq.intelligence.lexical; + +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class SnippetStoreTest { + + @TempDir + Path root; + + private final SnippetStore snippetStore = new SnippetStore(); + + @Test + void extractsSnippetWithContext() throws Exception { + Path file = root.resolve("MyClass.java"); + Files.writeString(file, """ + line1 + line2 + line3 + line4 + line5 + line6 + line7 + line8 + line9 + line10 + """); + + CodeNode node = new CodeNode("id1", NodeKind.CLASS, "MyClass"); + node.setFilePath("MyClass.java"); + node.setLineStart(5); + node.setLineEnd(5); + + Optional result = snippetStore.extract(node, root, 2); + assertThat(result).isPresent(); + CodeSnippet snippet = result.get(); + assertThat(snippet.lineStart()).isEqualTo(3); + assertThat(snippet.lineEnd()).isEqualTo(7); + assertThat(snippet.sourceText()).contains("line3").contains("line5").contains("line7"); + assertThat(snippet.filePath()).isEqualTo("MyClass.java"); + assertThat(snippet.language()).isEqualTo("java"); + } + + @Test + void returnsEmptyForMissingFilePath() { + CodeNode node = new CodeNode("id2", NodeKind.CLASS, "NoPath"); + // no filePath set + assertThat(snippetStore.extract(node, root)).isEmpty(); + } + + @Test + void returnsEmptyForMissingLineStart() { + CodeNode node = new CodeNode("id3", NodeKind.CLASS, "NoLine"); + node.setFilePath("SomeFile.java"); + // no lineStart set + assertThat(snippetStore.extract(node, root)).isEmpty(); + } + + @Test + void enforcesMaxLinesLimit() throws Exception { + // Write a 200-line file + var sb = new StringBuilder(); + for (int i = 1; i <= 200; i++) sb.append("line").append(i).append('\n'); + Path file = root.resolve("Big.java"); + Files.writeString(file, sb.toString()); + + CodeNode node = new CodeNode("id4", NodeKind.CLASS, "Big"); + node.setFilePath("Big.java"); + node.setLineStart(100); + node.setLineEnd(100); + + Optional result = snippetStore.extract(node, root, 100); + assertThat(result).isPresent(); + int lineCount = result.get().lineEnd() - result.get().lineStart() + 1; + assertThat(lineCount).isLessThanOrEqualTo(SnippetStore.MAX_LINES); + } + + @Test + void preventsPathTraversal() { + CodeNode node = new CodeNode("id5", NodeKind.CLASS, "Traversal"); + node.setFilePath("../../etc/passwd"); + node.setLineStart(1); + assertThat(snippetStore.extract(node, root)).isEmpty(); + } + + @Test + void inferredLanguageFromExtension() { + assertThat(SnippetStore.inferLanguage("Foo.java")).isEqualTo("java"); + assertThat(SnippetStore.inferLanguage("bar.ts")).isEqualTo("typescript"); + assertThat(SnippetStore.inferLanguage("baz.py")).isEqualTo("python"); + assertThat(SnippetStore.inferLanguage("main.go")).isEqualTo("go"); + assertThat(SnippetStore.inferLanguage("lib.rs")).isEqualTo("rust"); + assertThat(SnippetStore.inferLanguage("noext")).isEqualTo("unknown"); + } + + @Test + void extractionIsDeterministic() throws Exception { + Path file = root.resolve("Det.java"); + Files.writeString(file, "class Det {\n void go() {}\n}\n"); + + CodeNode node = new CodeNode("id6", NodeKind.CLASS, "Det"); + node.setFilePath("Det.java"); + node.setLineStart(1); + node.setLineEnd(3); + + Optional r1 = snippetStore.extract(node, root); + Optional r2 = snippetStore.extract(node, root); + assertThat(r1.map(CodeSnippet::sourceText)).isEqualTo(r2.map(CodeSnippet::sourceText)); + } +} 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..2c6468b5 --- /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", "cpp"); + } + + @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"); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsEvidenceTest.java b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsEvidenceTest.java new file mode 100644 index 00000000..342196b6 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsEvidenceTest.java @@ -0,0 +1,110 @@ +package io.github.randomcodespace.iq.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.intelligence.CapabilityLevel; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePack; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackAssembler; +import io.github.randomcodespace.iq.intelligence.evidence.EvidencePackRequest; +import io.github.randomcodespace.iq.intelligence.provenance.ArtifactMetadata; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class McpToolsEvidenceTest { + + @Mock private io.github.randomcodespace.iq.query.QueryService queryService; + @Mock private io.github.randomcodespace.iq.graph.GraphStore graphStore; + @Mock private org.neo4j.graphdb.GraphDatabaseService graphDb; + @Mock private io.github.randomcodespace.iq.query.StatsService statsService; + @Mock private io.github.randomcodespace.iq.query.TopologyService topologyService; + @Mock private EvidencePackAssembler assembler; + + private McpTools mcpTools; + private ArtifactMetadata metadata; + private final ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) + .disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + @BeforeEach + void setUp() { + CodeIqConfig config = new CodeIqConfig(); + metadata = new ArtifactMetadata( + "https://github.com/example/repo", "sha456", Instant.now(), + "1", "2", Map.of("code-iq", "1.0"), + Map.of(), "cafebabe"); + + mcpTools = new McpTools( + queryService, config, objectMapper, + Optional.empty(), graphDb, + statsService, topologyService, graphStore, + Optional.of(assembler), Optional.of(metadata)); + } + + @Test + void getEvidencePackReturnsPackJson() throws Exception { + EvidencePack pack = new EvidencePack( + List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), metadata, CapabilityLevel.PARTIAL); + when(assembler.assemble(any(EvidencePackRequest.class), any())).thenReturn(pack); + + String result = mcpTools.getEvidencePack("UserService", null, null, null); + assertThat(result).contains("capabilityLevel"); + assertThat(result).contains("PARTIAL"); + } + + @Test + void getEvidencePackReturnsErrorWhenAssemblerAbsent() { + McpTools noAssembler = new McpTools( + queryService, new CodeIqConfig(), objectMapper, + Optional.empty(), graphDb, + statsService, topologyService, graphStore, + Optional.empty(), Optional.empty()); + + String result = noAssembler.getEvidencePack("Foo", null, null, null); + assertThat(result).contains("error"); + } + + @Test + void getArtifactMetadataReturnsMetadataJson() { + String result = mcpTools.getArtifactMetadata(); + assertThat(result).contains("sha456"); + assertThat(result).contains("cafebabe"); + } + + @Test + void getArtifactMetadataReturnsErrorWhenAbsent() { + McpTools noMeta = new McpTools( + queryService, new CodeIqConfig(), objectMapper, + Optional.empty(), graphDb, + statsService, topologyService, graphStore, + Optional.empty(), Optional.empty()); + + String result = noMeta.getArtifactMetadata(); + assertThat(result).contains("error"); + } + + @Test + void getEvidencePackIsDeterministic() throws Exception { + EvidencePack pack = new EvidencePack( + List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), metadata, CapabilityLevel.EXACT); + when(assembler.assemble(any(EvidencePackRequest.class), any())).thenReturn(pack); + + String r1 = mcpTools.getEvidencePack("Svc", null, null, false); + String r2 = mcpTools.getEvidencePack("Svc", null, null, false); + assertThat(r1).isEqualTo(r2); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java index 59b8b697..94430244 100644 --- a/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java +++ b/src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java @@ -61,7 +61,7 @@ void setUp() { config = new CodeIqConfig(); config.setRootPath("."); objectMapper = new ObjectMapper(); - mcpTools = new McpTools(queryService, config, objectMapper, java.util.Optional.ofNullable(flowEngine), graphDb, statsService, new io.github.randomcodespace.iq.query.TopologyService(), graphStore); + mcpTools = new McpTools(queryService, config, objectMapper, java.util.Optional.ofNullable(flowEngine), graphDb, statsService, new io.github.randomcodespace.iq.query.TopologyService(), graphStore, java.util.Optional.empty(), java.util.Optional.empty()); } private Map parseJson(String json) throws IOException {