|
| 1 | +/* |
| 2 | + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one |
| 3 | + * or more contributor license agreements. Licensed under the "Elastic License |
| 4 | + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side |
| 5 | + * Public License v 1"; you may not use this file except in compliance with, at |
| 6 | + * your election, the "Elastic License 2.0", the "GNU Affero General Public |
| 7 | + * License v3.0 only", or the "Server Side Public License, v 1". |
| 8 | + */ |
| 9 | + |
| 10 | +import fs from 'fs'; |
| 11 | + |
| 12 | +// Use var for proper hoisting in Jest mocks (const/let have temporal dead zone issues) |
| 13 | +// eslint-disable-next-line no-var |
| 14 | +var mockExecFileAsync: jest.Mock; |
| 15 | + |
| 16 | +jest.mock('fs'); |
| 17 | +jest.mock('@kbn/repo-info', () => ({ REPO_ROOT: '/repo/root' })); |
| 18 | +jest.mock('child_process', () => ({ |
| 19 | + execFile: jest.fn(), |
| 20 | +})); |
| 21 | +jest.mock('util', () => { |
| 22 | + const actual = jest.requireActual('util'); |
| 23 | + mockExecFileAsync = jest.fn(); |
| 24 | + return { |
| 25 | + ...actual, |
| 26 | + promisify: jest.fn(() => mockExecFileAsync), |
| 27 | + }; |
| 28 | +}); |
| 29 | + |
| 30 | +import { searchByCodeownerTool } from './search_by_codeowner'; |
| 31 | + |
| 32 | +const mockedFs = fs as jest.Mocked<typeof fs>; |
| 33 | + |
| 34 | +describe('searchByCodeownerTool', () => { |
| 35 | + beforeEach(() => { |
| 36 | + jest.clearAllMocks(); |
| 37 | + }); |
| 38 | + |
| 39 | + const setupMocks = (options: { |
| 40 | + matchingFiles: string[]; |
| 41 | + codeowners?: string; |
| 42 | + grepError?: { code: number; stdout?: string; stderr?: string }; |
| 43 | + }) => { |
| 44 | + const { matchingFiles, codeowners, grepError } = options; |
| 45 | + |
| 46 | + // Mock CODEOWNERS file |
| 47 | + (mockedFs.readFileSync as jest.Mock).mockImplementation((filePath: string) => { |
| 48 | + if (filePath.includes('CODEOWNERS')) { |
| 49 | + return ( |
| 50 | + codeowners || |
| 51 | + ` |
| 52 | +/src @elastic/kibana-core |
| 53 | +/x-pack @elastic/kibana-platform |
| 54 | + `.trim() |
| 55 | + ); |
| 56 | + } |
| 57 | + throw new Error(`File not found: ${filePath}`); |
| 58 | + }); |
| 59 | + |
| 60 | + // Mock fs.statSync to check if directories exist |
| 61 | + (mockedFs.statSync as jest.Mock).mockImplementation((dirPath: string) => { |
| 62 | + const relativePath = dirPath.replace('/repo/root/', ''); |
| 63 | + // Assume src and x-pack directories exist |
| 64 | + if ( |
| 65 | + relativePath === 'src' || |
| 66 | + relativePath === 'x-pack' || |
| 67 | + relativePath.startsWith('src/') || |
| 68 | + relativePath.startsWith('x-pack/') |
| 69 | + ) { |
| 70 | + return { isDirectory: () => true, isFile: () => false }; |
| 71 | + } |
| 72 | + throw new Error('Path does not exist'); |
| 73 | + }); |
| 74 | + |
| 75 | + // Mock grep execFile |
| 76 | + if (grepError) { |
| 77 | + mockExecFileAsync.mockRejectedValue(grepError); |
| 78 | + } else { |
| 79 | + mockExecFileAsync.mockResolvedValue({ |
| 80 | + stdout: matchingFiles.map((f) => `/repo/root/${f}`).join('\n'), |
| 81 | + stderr: '', |
| 82 | + }); |
| 83 | + } |
| 84 | + }; |
| 85 | + |
| 86 | + describe('handler', () => { |
| 87 | + it('finds files containing search term owned by specified team', async () => { |
| 88 | + setupMocks({ |
| 89 | + matchingFiles: ['src/file1.ts', 'src/file2.tsx'], |
| 90 | + }); |
| 91 | + |
| 92 | + const result = await searchByCodeownerTool.handler({ |
| 93 | + searchTerm: 'TODO', |
| 94 | + team: '@elastic/kibana-core', |
| 95 | + }); |
| 96 | + |
| 97 | + const parsedResult = JSON.parse(result.content[0].text as string); |
| 98 | + |
| 99 | + expect(parsedResult.searchTerm).toBe('TODO'); |
| 100 | + expect(parsedResult.team).toBe('@elastic/kibana-core'); |
| 101 | + expect(parsedResult.totalMatchingFiles).toBe(2); |
| 102 | + expect(parsedResult.matchingFiles).toContain('src/file1.ts'); |
| 103 | + expect(parsedResult.matchingFiles).toContain('src/file2.tsx'); |
| 104 | + expect(parsedResult.totalScannedFiles).toBe(1); // 1 directory searched |
| 105 | + }); |
| 106 | + |
| 107 | + it('normalizes team name by adding @ prefix if missing', async () => { |
| 108 | + setupMocks({ |
| 109 | + matchingFiles: ['src/file1.ts'], |
| 110 | + }); |
| 111 | + |
| 112 | + const result = await searchByCodeownerTool.handler({ |
| 113 | + searchTerm: 'TODO', |
| 114 | + team: 'elastic/kibana-core', |
| 115 | + }); |
| 116 | + |
| 117 | + const parsedResult = JSON.parse(result.content[0].text as string); |
| 118 | + |
| 119 | + expect(parsedResult.team).toBe('@elastic/kibana-core'); |
| 120 | + expect(parsedResult.totalMatchingFiles).toBe(1); |
| 121 | + }); |
| 122 | + |
| 123 | + it('returns zero results when no files match', async () => { |
| 124 | + setupMocks({ |
| 125 | + matchingFiles: [], |
| 126 | + grepError: { |
| 127 | + code: 1, |
| 128 | + stdout: '', |
| 129 | + }, |
| 130 | + }); |
| 131 | + |
| 132 | + const result = await searchByCodeownerTool.handler({ |
| 133 | + searchTerm: 'NONEXISTENT', |
| 134 | + team: '@elastic/kibana-core', |
| 135 | + }); |
| 136 | + |
| 137 | + const parsedResult = JSON.parse(result.content[0].text as string); |
| 138 | + |
| 139 | + expect(parsedResult.totalMatchingFiles).toBe(0); |
| 140 | + expect(parsedResult.matchingFiles).toHaveLength(0); |
| 141 | + }); |
| 142 | + |
| 143 | + it('returns zero results when team has no owned paths', async () => { |
| 144 | + setupMocks({ |
| 145 | + matchingFiles: [], |
| 146 | + codeowners: ` |
| 147 | +/src @elastic/kibana-core |
| 148 | +/x-pack @elastic/kibana-platform |
| 149 | + `.trim(), |
| 150 | + }); |
| 151 | + |
| 152 | + const result = await searchByCodeownerTool.handler({ |
| 153 | + searchTerm: 'TODO', |
| 154 | + team: '@elastic/kibana-security', |
| 155 | + }); |
| 156 | + |
| 157 | + const parsedResult = JSON.parse(result.content[0].text as string); |
| 158 | + |
| 159 | + expect(parsedResult.totalMatchingFiles).toBe(0); |
| 160 | + expect(parsedResult.totalScannedFiles).toBe(0); // No directories for this team |
| 161 | + }); |
| 162 | + |
| 163 | + it('only searches in directories that exist', async () => { |
| 164 | + (mockedFs.readFileSync as jest.Mock).mockImplementation((filePath: string) => { |
| 165 | + if (filePath.includes('CODEOWNERS')) { |
| 166 | + return ` |
| 167 | +/src @elastic/kibana-core |
| 168 | +/nonexistent @elastic/kibana-core |
| 169 | + `.trim(); |
| 170 | + } |
| 171 | + throw new Error(`File not found: ${filePath}`); |
| 172 | + }); |
| 173 | + |
| 174 | + (mockedFs.statSync as jest.Mock).mockImplementation((dirPath: string) => { |
| 175 | + if (dirPath.includes('nonexistent')) { |
| 176 | + throw new Error('Path does not exist'); |
| 177 | + } |
| 178 | + return { isDirectory: () => true, isFile: () => false }; |
| 179 | + }); |
| 180 | + |
| 181 | + mockExecFileAsync.mockResolvedValue({ |
| 182 | + stdout: '/repo/root/src/file1.ts', |
| 183 | + stderr: '', |
| 184 | + }); |
| 185 | + |
| 186 | + const result = await searchByCodeownerTool.handler({ |
| 187 | + searchTerm: 'TODO', |
| 188 | + team: '@elastic/kibana-core', |
| 189 | + }); |
| 190 | + |
| 191 | + const parsedResult = JSON.parse(result.content[0].text as string); |
| 192 | + |
| 193 | + // Should only search in /src (which exists), not /nonexistent |
| 194 | + expect(parsedResult.totalScannedFiles).toBe(1); |
| 195 | + expect(parsedResult.totalMatchingFiles).toBe(1); |
| 196 | + }); |
| 197 | + |
| 198 | + it('handles grep errors gracefully', async () => { |
| 199 | + setupMocks({ |
| 200 | + matchingFiles: [], |
| 201 | + grepError: { |
| 202 | + code: 2, |
| 203 | + stderr: 'grep error', |
| 204 | + }, |
| 205 | + }); |
| 206 | + |
| 207 | + const result = await searchByCodeownerTool.handler({ |
| 208 | + searchTerm: 'TODO', |
| 209 | + team: '@elastic/kibana-core', |
| 210 | + }); |
| 211 | + |
| 212 | + const parsedResult = JSON.parse(result.content[0].text as string); |
| 213 | + |
| 214 | + expect(parsedResult.totalMatchingFiles).toBe(0); |
| 215 | + }); |
| 216 | + |
| 217 | + it('handles grep exit code 1 with stdout', async () => { |
| 218 | + setupMocks({ |
| 219 | + matchingFiles: [], |
| 220 | + grepError: { |
| 221 | + code: 1, |
| 222 | + stdout: '/repo/root/src/file1.ts\n/repo/root/src/file2.ts', |
| 223 | + }, |
| 224 | + }); |
| 225 | + |
| 226 | + const result = await searchByCodeownerTool.handler({ |
| 227 | + searchTerm: 'TODO', |
| 228 | + team: '@elastic/kibana-core', |
| 229 | + }); |
| 230 | + |
| 231 | + const parsedResult = JSON.parse(result.content[0].text as string); |
| 232 | + |
| 233 | + expect(parsedResult.totalMatchingFiles).toBe(2); |
| 234 | + expect(parsedResult.matchingFiles).toContain('src/file1.ts'); |
| 235 | + expect(parsedResult.matchingFiles).toContain('src/file2.ts'); |
| 236 | + }); |
| 237 | + |
| 238 | + it('returns analysis time in milliseconds', async () => { |
| 239 | + setupMocks({ |
| 240 | + matchingFiles: ['src/file1.ts'], |
| 241 | + }); |
| 242 | + |
| 243 | + const result = await searchByCodeownerTool.handler({ |
| 244 | + searchTerm: 'TODO', |
| 245 | + team: '@elastic/kibana-core', |
| 246 | + }); |
| 247 | + |
| 248 | + const parsedResult = JSON.parse(result.content[0].text as string); |
| 249 | + |
| 250 | + expect(parsedResult.analysisTimeMs).toBeGreaterThanOrEqual(0); |
| 251 | + expect(typeof parsedResult.analysisTimeMs).toBe('number'); |
| 252 | + }); |
| 253 | + |
| 254 | + it('sorts matching files alphabetically', async () => { |
| 255 | + setupMocks({ |
| 256 | + matchingFiles: ['src/zzz.ts', 'src/aaa.ts', 'src/mmm.ts'], |
| 257 | + }); |
| 258 | + |
| 259 | + const result = await searchByCodeownerTool.handler({ |
| 260 | + searchTerm: 'TODO', |
| 261 | + team: '@elastic/kibana-core', |
| 262 | + }); |
| 263 | + |
| 264 | + const parsedResult = JSON.parse(result.content[0].text as string); |
| 265 | + |
| 266 | + expect(parsedResult.matchingFiles).toEqual(['src/aaa.ts', 'src/mmm.ts', 'src/zzz.ts']); |
| 267 | + }); |
| 268 | + |
| 269 | + it('converts absolute paths to relative paths', async () => { |
| 270 | + setupMocks({ |
| 271 | + matchingFiles: ['src/file1.ts'], |
| 272 | + }); |
| 273 | + |
| 274 | + const result = await searchByCodeownerTool.handler({ |
| 275 | + searchTerm: 'TODO', |
| 276 | + team: '@elastic/kibana-core', |
| 277 | + }); |
| 278 | + |
| 279 | + const parsedResult = JSON.parse(result.content[0].text as string); |
| 280 | + |
| 281 | + // Should return relative paths, not absolute |
| 282 | + expect(parsedResult.matchingFiles[0]).not.toContain('/repo/root'); |
| 283 | + expect(parsedResult.matchingFiles[0]).toBe('src/file1.ts'); |
| 284 | + }); |
| 285 | + }); |
| 286 | + |
| 287 | + describe('tool definition', () => { |
| 288 | + it('has correct name', () => { |
| 289 | + expect(searchByCodeownerTool.name).toBe('search_by_codeowner'); |
| 290 | + }); |
| 291 | + |
| 292 | + it('has a description', () => { |
| 293 | + expect(searchByCodeownerTool.description).toBeTruthy(); |
| 294 | + expect(typeof searchByCodeownerTool.description).toBe('string'); |
| 295 | + }); |
| 296 | + |
| 297 | + it('has an input schema', () => { |
| 298 | + expect(searchByCodeownerTool.inputSchema).toBeDefined(); |
| 299 | + }); |
| 300 | + |
| 301 | + it('has a handler function', () => { |
| 302 | + expect(searchByCodeownerTool.handler).toBeDefined(); |
| 303 | + expect(typeof searchByCodeownerTool.handler).toBe('function'); |
| 304 | + }); |
| 305 | + }); |
| 306 | +}); |
0 commit comments