diff --git a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts index 2667b115..a781da35 100644 --- a/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts +++ b/packages/code-analyzer-apexguru-engine/src/services/ApexGuruService.ts @@ -88,6 +88,50 @@ export class ApexGuruService { } } + /** + * Export curl commands for debugging/documentation purposes + * Returns curl commands for validate, submit, and query endpoints + * Note: Requires initialize() to be called first for valid authentication + */ + exportCurlCommands(): { + validate: string; + submit: (classContent: string) => string; + query: (requestId: string) => string; + } { + try { + const apiVersion = this.authService.getApiVersion(); + + return { + validate: this.generateCurlCommand( + 'GET', + `/services/data/v${apiVersion}/apexguru/validate` + ), + submit: (classContent: string) => { + const base64Content = Buffer.from(classContent, 'utf-8').toString('base64'); + const requestBody = { classContent: base64Content }; + return this.generateCurlCommand( + 'POST', + `/services/data/v${apiVersion}/apexguru/request`, + JSON.stringify(requestBody) + ); + }, + query: (requestId: string) => { + const url = requestId === 'pending' + ? `/services/data/v${apiVersion}/apexguru/request` + : `/services/data/v${apiVersion}/apexguru/request/${requestId}`; + return this.generateCurlCommand('GET', url); + } + }; + } catch { + const notInitialized = '# Connection not initialized'; + return { + validate: notInitialized, + submit: () => notInitialized, + query: () => notInitialized + }; + } + } + /** * Validate ApexGuru access * Throws error with specific context if validation fails @@ -114,6 +158,11 @@ export class ApexGuruService { const apiVersion = this.authService.getApiVersion(); const url = `/services/data/v${apiVersion}/apexguru/validate`; + if (process.env.APEXGURU_DEBUG_CURL) { + const curlCmd = this.generateCurlCommand('GET', url); + this.emitLogEvent(LogLevel.Fine, `Equivalent curl command:\n${curlCmd}`); + } + const response = await connection.request({ method: 'GET', url @@ -173,12 +222,18 @@ export class ApexGuruService { const base64Content = Buffer.from(classContent, 'utf-8').toString('base64'); const requestBody = { classContent: base64Content }; + const body = JSON.stringify(requestBody); + + if (process.env.APEXGURU_DEBUG_CURL) { + const curlCmd = this.generateCurlCommand('POST', url, body); + this.emitLogEvent(LogLevel.Fine, `Equivalent curl command:\n${curlCmd}`); + } try { const response: ApexGuruInitialResponse = await connection.request({ method: 'POST', url, - body: JSON.stringify(requestBody), + body, headers: { 'Content-Type': 'application/json' } }); @@ -234,6 +289,11 @@ export class ApexGuruService { this.progressCallback(asymptoticProgress); } + if (process.env.APEXGURU_DEBUG_CURL) { + const curlCmd = this.generateCurlCommand('GET', url); + this.emitLogEvent(LogLevel.Fine, `Equivalent curl command:\n${curlCmd}`); + } + const response: ApexGuruQueryResponse = await connection.request({ method: 'GET', url @@ -289,4 +349,31 @@ export class ApexGuruService { private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } + + /** + * Generate equivalent curl command for debugging + */ + private generateCurlCommand(method: string, url: string, body?: string): string { + try { + const connection = this.authService.getConnection(); + if (!connection || !connection.instanceUrl || !connection.accessToken) { + return '# Connection not initialized'; + } + + const fullUrl = `${connection.instanceUrl}${url}`; + const lines: string[] = [`curl -X ${method}`]; + lines.push(` '${fullUrl}'`); + lines.push(` -H "Authorization: Bearer ${connection.accessToken}"`); + + if (body) { + lines.push(` -H "Content-Type: application/json"`); + const escapedBody = body.replace(/'/g, "'\\''"); + lines.push(` -d '${escapedBody}'`); + } + + return lines.join(' \\\n'); + } catch { + return '# Connection not initialized'; + } + } } diff --git a/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts b/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts index 0cfc0330..eace7f56 100644 --- a/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts +++ b/packages/code-analyzer-apexguru-engine/test/ApexGuruService.test.ts @@ -415,4 +415,239 @@ describe('ApexGuruService', () => { expect(() => apexGuruService.cleanup()).not.toThrow(); }); }); + + describe('generateCurlCommand', () => { + beforeEach(async () => { + // Initialize to set up connection + await apexGuruService.initialize(); + }); + + it('should generate valid curl command for GET validate endpoint', () => { + const curlCmd = (apexGuruService as any).generateCurlCommand( + 'GET', + '/services/data/v64.0/apexguru/validate' + ); + + expect(curlCmd).toContain('curl -X GET'); + expect(curlCmd).toContain('https://test.salesforce.com/services/data/v64.0/apexguru/validate'); + expect(curlCmd).toContain('-H "Authorization: Bearer test-token"'); + }); + + it('should generate valid curl command for POST submit endpoint with body', () => { + const testContent = 'public class Test { }'; + const base64Content = Buffer.from(testContent, 'utf-8').toString('base64'); + const requestBody = { classContent: base64Content }; + + const curlCmd = (apexGuruService as any).generateCurlCommand( + 'POST', + '/services/data/v64.0/apexguru/request', + JSON.stringify(requestBody) + ); + + expect(curlCmd).toContain('curl -X POST'); + expect(curlCmd).toContain('https://test.salesforce.com/services/data/v64.0/apexguru/request'); + expect(curlCmd).toContain('-H "Authorization: Bearer test-token"'); + expect(curlCmd).toContain('-H "Content-Type: application/json"'); + expect(curlCmd).toContain(`-d '${JSON.stringify(requestBody)}'`); + }); + + it('should generate valid curl command for GET query endpoint with requestId', () => { + const requestId = 'req-123'; + const curlCmd = (apexGuruService as any).generateCurlCommand( + 'GET', + `/services/data/v64.0/apexguru/request/${requestId}` + ); + + expect(curlCmd).toContain('curl -X GET'); + expect(curlCmd).toContain(`https://test.salesforce.com/services/data/v64.0/apexguru/request/${requestId}`); + expect(curlCmd).toContain('-H "Authorization: Bearer test-token"'); + }); + + it('should properly escape single quotes in body', () => { + const bodyWithQuotes = JSON.stringify({ data: "It's a test" }); + const curlCmd = (apexGuruService as any).generateCurlCommand( + 'POST', + '/services/data/v64.0/apexguru/request', + bodyWithQuotes + ); + + expect(curlCmd).toContain(`-d '${bodyWithQuotes.replace(/'/g, "'\\''")}'`); + }); + + it('should return graceful message when connection not initialized', () => { + const uninitializedAuthService = { + initialize: jest.fn(), + getConnection: jest.fn().mockReturnValue(null), + getAccessToken: jest.fn(), + getInstanceUrl: jest.fn(), + getApiVersion: jest.fn(), + mintOrgJwt: jest.fn() + } as any; + + jest.mocked(ApexGuruAuthService).mockImplementationOnce(() => uninitializedAuthService); + + const uninitializedService = new ApexGuruService( + mockEmitLogEvent, + 120000, + 2000, + 60000, + 2 + ); + + const curlCmd = (uninitializedService as any).generateCurlCommand( + 'GET', + '/services/data/v64.0/apexguru/validate' + ); + + expect(curlCmd).toBe('# Connection not initialized'); + }); + }); + + describe('debug curl logging', () => { + const originalEnv = process.env.APEXGURU_DEBUG_CURL; + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.APEXGURU_DEBUG_CURL = originalEnv; + } else { + delete process.env.APEXGURU_DEBUG_CURL; + } + }); + + beforeEach(async () => { + await apexGuruService.initialize(); + }); + + it('should log curl command for validate when APEXGURU_DEBUG_CURL is set', async () => { + process.env.APEXGURU_DEBUG_CURL = '1'; + + (mockConnection.request as jest.Mock).mockResolvedValue({ + status: ApexGuruResponseStatus.SUCCESS + }); + + await apexGuruService.validate(); + + expect(mockEmitLogEvent).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Equivalent curl command:') + ); + expect(mockEmitLogEvent).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('curl -X GET') + ); + }); + + it('should not log curl command for validate when APEXGURU_DEBUG_CURL is not set', async () => { + delete process.env.APEXGURU_DEBUG_CURL; + + (mockConnection.request as jest.Mock).mockResolvedValue({ + status: ApexGuruResponseStatus.SUCCESS + }); + + await apexGuruService.validate(); + + const curlLogs = mockEmitLogEvent.mock.calls.filter((call: any[]) => + call[1] && call[1].includes('curl') + ); + expect(curlLogs).toHaveLength(0); + }); + + it('should log curl command for submit when APEXGURU_DEBUG_CURL is set', async () => { + process.env.APEXGURU_DEBUG_CURL = '1'; + + (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + status: ApexGuruResponseStatus.NEW, + requestId: 'req-123' + }); + + (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + status: ApexGuruResponseStatus.SUCCESS, + report: Buffer.from(JSON.stringify([])).toString('base64') + }); + + await apexGuruService.analyzeApexClass('public class Test { }', '/test/Test.cls'); + + const curlLogs = mockEmitLogEvent.mock.calls.filter((call: any[]) => + call[1] && call[1].includes('Equivalent curl command:') + ); + expect(curlLogs.length).toBeGreaterThanOrEqual(2); // At least submit and poll + }); + + it('should log curl command for poll when APEXGURU_DEBUG_CURL is set', async () => { + process.env.APEXGURU_DEBUG_CURL = '1'; + + (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + status: ApexGuruResponseStatus.NEW, + requestId: 'req-123' + }); + + (mockConnection.request as jest.Mock).mockResolvedValueOnce({ + status: ApexGuruResponseStatus.SUCCESS, + report: Buffer.from(JSON.stringify([])).toString('base64') + }); + + await apexGuruService.analyzeApexClass('public class Test { }', '/test/Test.cls'); + + const curlWithRequestId = mockEmitLogEvent.mock.calls.filter((call: any[]) => + call[1] && call[1].includes('curl') && call[1].includes('req-123') + ); + expect(curlWithRequestId.length).toBeGreaterThan(0); + }); + }); + + describe('exportCurlCommands', () => { + beforeEach(async () => { + await apexGuruService.initialize(); + }); + + it('should return object with validate, submit, and query curl commands', () => { + const exported = apexGuruService.exportCurlCommands(); + + expect(exported).toHaveProperty('validate'); + expect(exported).toHaveProperty('submit'); + expect(exported).toHaveProperty('query'); + expect(typeof exported.validate).toBe('string'); + expect(typeof exported.submit).toBe('function'); + expect(typeof exported.query).toBe('function'); + }); + + it('should generate valid validate curl command', () => { + const exported = apexGuruService.exportCurlCommands(); + + expect(exported.validate).toContain('curl -X GET'); + expect(exported.validate).toContain('https://test.salesforce.com/services/data/v64.0/apexguru/validate'); + expect(exported.validate).toContain('Authorization: Bearer test-token'); + }); + + it('should generate valid submit curl command with class content', () => { + const exported = apexGuruService.exportCurlCommands(); + const testContent = 'public class Test { }'; + const submitCmd = exported.submit(testContent); + + expect(submitCmd).toContain('curl -X POST'); + expect(submitCmd).toContain('https://test.salesforce.com/services/data/v64.0/apexguru/request'); + expect(submitCmd).toContain('Authorization: Bearer test-token'); + expect(submitCmd).toContain('Content-Type: application/json'); + expect(submitCmd).toContain(Buffer.from(testContent).toString('base64')); + }); + + it('should generate valid query curl command with requestId', () => { + const exported = apexGuruService.exportCurlCommands(); + const requestId = 'req-123'; + const queryCmd = exported.query(requestId); + + expect(queryCmd).toContain('curl -X GET'); + expect(queryCmd).toContain(`https://test.salesforce.com/services/data/v64.0/apexguru/request/${requestId}`); + expect(queryCmd).toContain('Authorization: Bearer test-token'); + }); + + it('should handle pending requestId for query', () => { + const exported = apexGuruService.exportCurlCommands(); + const queryCmd = exported.query('pending'); + + expect(queryCmd).toContain('curl -X GET'); + expect(queryCmd).toContain('https://test.salesforce.com/services/data/v64.0/apexguru/request'); + expect(queryCmd).not.toContain('request/pending'); + }); + }); }); diff --git a/packages/code-analyzer-apexguru-engine/test/integration/curl.test.ts b/packages/code-analyzer-apexguru-engine/test/integration/curl.test.ts new file mode 100644 index 00000000..5859702c --- /dev/null +++ b/packages/code-analyzer-apexguru-engine/test/integration/curl.test.ts @@ -0,0 +1,88 @@ +import { ApexGuruService } from '../../src/services/ApexGuruService'; +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execAsync = promisify(exec); + +describe('Curl Command Integration Tests', () => { + let apexGuruService: ApexGuruService; + const mockEmitLogEvent = jest.fn(); + + beforeEach(() => { + apexGuruService = new ApexGuruService( + mockEmitLogEvent, + 120000, + 2000, + 60000, + 2 + ); + }); + + // Skip test if no authentication is available + const skipIfNoAuth = process.env.SF_TARGET_ORG ? test : test.skip; + + skipIfNoAuth('should generate executable curl command for validate endpoint', async () => { + // Initialize with real authentication + await apexGuruService.initialize(process.env.SF_TARGET_ORG); + + // Export curl commands + const exported = apexGuruService.exportCurlCommands(); + const curlCmd = exported.validate; + + // Execute curl command + const { stdout } = await execAsync(curlCmd); + const response = JSON.parse(stdout); + + // Verify response has expected status field + expect(response).toHaveProperty('status'); + expect(typeof response.status).toBe('string'); + }, 30000); + + skipIfNoAuth('should generate executable curl command for submit endpoint', async () => { + await apexGuruService.initialize(process.env.SF_TARGET_ORG); + + const testContent = 'public class CurlTest { public void test() { System.debug("test"); } }'; + const exported = apexGuruService.exportCurlCommands(); + const curlCmd = exported.submit(testContent); + + // Execute curl command + const { stdout } = await execAsync(curlCmd); + const response = JSON.parse(stdout); + + // Verify response has expected fields + expect(response).toHaveProperty('status'); + expect(['new', 'success', 'failed']).toContain(response.status.toLowerCase()); + }, 30000); + + skipIfNoAuth('should generate executable curl command for query endpoint', async () => { + await apexGuruService.initialize(process.env.SF_TARGET_ORG); + + // First submit a request to get a requestId + const testContent = 'public class CurlTest { }'; + const exported = apexGuruService.exportCurlCommands(); + + const submitCmd = exported.submit(testContent); + const { stdout: submitStdout } = await execAsync(submitCmd); + const submitResponse = JSON.parse(submitStdout); + + // Use the requestId to query (or use 'pending' if no requestId) + const requestId = submitResponse.requestId || 'pending'; + const queryCmd = exported.query(requestId); + + const { stdout: queryStdout } = await execAsync(queryCmd); + const queryResponse = JSON.parse(queryStdout); + + // Verify response structure + expect(queryResponse).toHaveProperty('status'); + expect(typeof queryResponse.status).toBe('string'); + }, 30000); + + test('should generate valid curl commands even without authentication', () => { + // Don't initialize - test graceful handling + const exported = apexGuruService.exportCurlCommands(); + + expect(exported.validate).toContain('# Connection not initialized'); + expect(exported.submit('test')).toContain('# Connection not initialized'); + expect(exported.query('req-123')).toContain('# Connection not initialized'); + }); +}); diff --git a/packages/code-analyzer-sfge-engine/sfge/src/test/resources/test-files/com/salesforce/graph/ops/GraphUtilTest/testSkipsSfdxFolders/.sfdx/tools/246/StandardApexLibrary/System.AccessLevel.cls b/packages/code-analyzer-sfge-engine/sfge/src/test/resources/test-files/com/salesforce/graph/ops/GraphUtilTest/testSkipsSfdxFolders/.sfdx/tools/246/StandardApexLibrary/System.AccessLevel.cls deleted file mode 100644 index 16bbd4af..00000000 --- a/packages/code-analyzer-sfge-engine/sfge/src/test/resources/test-files/com/salesforce/graph/ops/GraphUtilTest/testSkipsSfdxFolders/.sfdx/tools/246/StandardApexLibrary/System.AccessLevel.cls +++ /dev/null @@ -1,2 +0,0 @@ -How much wood would a woodchuck chuck, if a woodchuck could chuck wood? -Well, it would chuck as much wood as a woodchuck would if a woodchuck could chuck wood. diff --git a/packages/code-analyzer-sfge-engine/sfge/src/test/resources/test-files/com/salesforce/graph/ops/GraphUtilTest/testSkipsSfdxFolders/proj1/.sfdx/StandardApexLibrary/System.AccessLevel.cls b/packages/code-analyzer-sfge-engine/sfge/src/test/resources/test-files/com/salesforce/graph/ops/GraphUtilTest/testSkipsSfdxFolders/proj1/.sfdx/StandardApexLibrary/System.AccessLevel.cls deleted file mode 100644 index 16bbd4af..00000000 --- a/packages/code-analyzer-sfge-engine/sfge/src/test/resources/test-files/com/salesforce/graph/ops/GraphUtilTest/testSkipsSfdxFolders/proj1/.sfdx/StandardApexLibrary/System.AccessLevel.cls +++ /dev/null @@ -1,2 +0,0 @@ -How much wood would a woodchuck chuck, if a woodchuck could chuck wood? -Well, it would chuck as much wood as a woodchuck would if a woodchuck could chuck wood.