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 @@