diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 13d18ce9..00cf0975 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,28 +5,6 @@ export function sanitizeUrlParam(param: string): string { return param.replace(/[;&|`$(){}[\]<>]/g, ""); } -export interface HarFile { - log: { - entries: HarEntry[]; - }; -} - -export interface HarEntry { - startedDateTime: string; - request: { - method: string; - url: string; - queryString?: { name: string; value: string }[]; - }; - response: { - status: number; - statusText?: string; - _error?: string; - }; - serverIPAddress?: string; - time?: number; -} - const ONE_MB = 1048576; //Compresses a base64 image intelligently to keep it under 1 MB if needed. @@ -56,15 +34,3 @@ export async function assertOkResponse(response: Response, action: string) { ); } } - -export function filterLinesByKeywords( - logText: string, - keywords: string[], -): string[] { - return logText - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => - keywords.some((keyword) => line.toLowerCase().includes(keyword)), - ); -} diff --git a/src/tools/failurelogs-utils/app-automate.ts b/src/tools/failurelogs-utils/app-automate.ts index 7f4a3b0f..8c9c18c1 100644 --- a/src/tools/failurelogs-utils/app-automate.ts +++ b/src/tools/failurelogs-utils/app-automate.ts @@ -1,5 +1,8 @@ import config from "../../config.js"; -import { assertOkResponse, filterLinesByKeywords } from "../../lib/utils.js"; +import { + filterLinesByKeywords, + validateLogResponse, +} from "./utils.js"; const auth = Buffer.from( `${config.browserstackUsername}:${config.browserstackAccessKey}`, @@ -9,7 +12,7 @@ const auth = Buffer.from( export async function retrieveDeviceLogs( sessionId: string, buildId: string, -): Promise { +): Promise { const url = `https://api.browserstack.com/app-automate/builds/${buildId}/sessions/${sessionId}/deviceLogs`; const response = await fetch(url, { @@ -19,17 +22,21 @@ export async function retrieveDeviceLogs( }, }); - await assertOkResponse(response, "device logs"); + const validationError = validateLogResponse(response, "device logs"); + if (validationError) return validationError.message!; const logText = await response.text(); - return filterDeviceFailures(logText); + const logs = filterDeviceFailures(logText); + return logs.length > 0 + ? `Device Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` + : "No device failures found"; } // APPIUM LOGS export async function retrieveAppiumLogs( sessionId: string, buildId: string, -): Promise { +): Promise { const url = `https://api.browserstack.com/app-automate/builds/${buildId}/sessions/${sessionId}/appiumlogs`; const response = await fetch(url, { @@ -39,17 +46,21 @@ export async function retrieveAppiumLogs( }, }); - await assertOkResponse(response, "Appium logs"); + const validationError = validateLogResponse(response, "Appium logs"); + if (validationError) return validationError.message!; const logText = await response.text(); - return filterAppiumFailures(logText); + const logs = filterAppiumFailures(logText); + return logs.length > 0 + ? `Appium Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` + : "No Appium failures found"; } // CRASH LOGS export async function retrieveCrashLogs( sessionId: string, buildId: string, -): Promise { +): Promise { const url = `https://api.browserstack.com/app-automate/builds/${buildId}/sessions/${sessionId}/crashlogs`; const response = await fetch(url, { @@ -59,14 +70,17 @@ export async function retrieveCrashLogs( }, }); - await assertOkResponse(response, "crash logs"); + const validationError = validateLogResponse(response, "crash logs"); + if (validationError) return validationError.message!; const logText = await response.text(); - return filterCrashFailures(logText); + const logs = filterCrashFailures(logText); + return logs.length > 0 + ? `Crash Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` + : "No crash failures found"; } // FILTER HELPERS - export function filterDeviceFailures(logText: string): string[] { const keywords = [ "error", diff --git a/src/tools/failurelogs-utils/automate.ts b/src/tools/failurelogs-utils/automate.ts index 37efb7e6..8f490bf2 100644 --- a/src/tools/failurelogs-utils/automate.ts +++ b/src/tools/failurelogs-utils/automate.ts @@ -1,13 +1,19 @@ import config from "../../config.js"; -import { HarEntry, HarFile } from "../../lib/utils.js"; -import { assertOkResponse, filterLinesByKeywords } from "../../lib/utils.js"; +import { + HarEntry, + HarFile, + filterLinesByKeywords, + validateLogResponse, +} from "./utils.js"; const auth = Buffer.from( `${config.browserstackUsername}:${config.browserstackAccessKey}`, ).toString("base64"); // NETWORK LOGS -export async function retrieveNetworkFailures(sessionId: string): Promise { +export async function retrieveNetworkFailures( + sessionId: string, +): Promise { const url = `https://api.browserstack.com/automate/sessions/${sessionId}/networklogs`; const response = await fetch(url, { @@ -18,43 +24,40 @@ export async function retrieveNetworkFailures(sessionId: string): Promise { }, }); - await assertOkResponse(response, "network logs"); + const validationError = validateLogResponse(response, "network logs"); + if (validationError) return validationError.message!; const networklogs: HarFile = await response.json(); - - // Filter for failure logs const failureEntries: HarEntry[] = networklogs.log.entries.filter( - (entry: HarEntry) => { - return ( - entry.response.status === 0 || - entry.response.status >= 400 || - entry.response._error !== undefined - ); - }, + (entry: HarEntry) => + entry.response.status === 0 || + entry.response.status >= 400 || + entry.response._error !== undefined ); - - // Return only the failure entries with some context - return failureEntries.map((entry: any) => ({ - startedDateTime: entry.startedDateTime, - request: { - method: entry.request?.method, - url: entry.request?.url, - queryString: entry.request?.queryString, - }, - response: { - status: entry.response?.status, - statusText: entry.response?.statusText, - _error: entry.response?._error, - }, - serverIPAddress: entry.serverIPAddress, - time: entry.time, - })); + + return failureEntries.length > 0 + ? `Network Failures (${failureEntries.length} found):\n${JSON.stringify(failureEntries.map((entry: any) => ({ + startedDateTime: entry.startedDateTime, + request: { + method: entry.request?.method, + url: entry.request?.url, + queryString: entry.request?.queryString, + }, + response: { + status: entry.response?.status, + statusText: entry.response?.statusText, + _error: entry.response?._error, + }, + serverIPAddress: entry.serverIPAddress, + time: entry.time, + })), null, 2)}` + : "No network failures found"; } // SESSION LOGS export async function retrieveSessionFailures( sessionId: string, -): Promise { +): Promise { const url = `https://api.browserstack.com/automate/sessions/${sessionId}/logs`; const response = await fetch(url, { @@ -64,16 +67,20 @@ export async function retrieveSessionFailures( }, }); - await assertOkResponse(response, "session logs"); + const validationError = validateLogResponse(response, "session logs"); + if (validationError) return validationError.message!; const logText = await response.text(); - return filterSessionFailures(logText); + const logs = filterSessionFailures(logText); + return logs.length > 0 + ? `Session Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` + : "No session failures found"; } // CONSOLE LOGS export async function retrieveConsoleFailures( sessionId: string, -): Promise { +): Promise { const url = `https://api.browserstack.com/automate/sessions/${sessionId}/consolelogs`; const response = await fetch(url, { @@ -83,10 +90,14 @@ export async function retrieveConsoleFailures( }, }); - await assertOkResponse(response, "console logs"); + const validationError = validateLogResponse(response, "console logs"); + if (validationError) return validationError.message!; const logText = await response.text(); - return filterConsoleFailures(logText); + const logs = filterConsoleFailures(logText); + return logs.length > 0 + ? `Console Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` + : "No console failures found"; } // FILTER: session logs diff --git a/src/tools/failurelogs-utils/utils.ts b/src/tools/failurelogs-utils/utils.ts new file mode 100644 index 00000000..68f617b3 --- /dev/null +++ b/src/tools/failurelogs-utils/utils.ts @@ -0,0 +1,56 @@ +export interface LogResponse { + logs?: any[]; + message?: string; +} + +export interface HarFile { + log: { + entries: HarEntry[]; + }; +} + +export interface HarEntry { + startedDateTime: string; + request: { + method: string; + url: string; + queryString?: { name: string; value: string }[]; + }; + response: { + status: number; + statusText?: string; + _error?: string; + }; + serverIPAddress?: string; + time?: number; +} + +export function validateLogResponse( + response: Response, + logType: string, +): LogResponse | null { + if (!response.ok) { + if (response.status === 404) { + return { message: `No ${logType} available for this session` }; + } + if (response.status === 401 || response.status === 403) { + return { + message: `Unable to access ${logType} - please check your credentials`, + }; + } + return { message: `Unable to fetch ${logType} at this time` }; + } + return null; +} + +export function filterLinesByKeywords( + logText: string, + keywords: string[], +): string[] { + return logText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => + keywords.some((keyword) => line.toLowerCase().includes(keyword)), + ); +} diff --git a/src/tools/getFailureLogs.ts b/src/tools/getFailureLogs.ts index 33dc1848..e6e8b7d2 100644 --- a/src/tools/getFailureLogs.ts +++ b/src/tools/getFailureLogs.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import logger from "../logger.js"; +import { trackMCP } from "../lib/instrumentation.js"; import { retrieveNetworkFailures, @@ -14,7 +15,7 @@ import { retrieveAppiumLogs, retrieveCrashLogs, } from "./failurelogs-utils/app-automate.js"; -import { trackMCP } from "../lib/instrumentation.js"; + import { AppAutomateLogType, AutomateLogType, @@ -90,80 +91,44 @@ export async function getFailureLogs(args: { ], }; } - + let response; // eslint-disable-next-line no-useless-catch try { for (const logType of validLogTypes) { switch (logType) { case AutomateLogType.NetworkLogs: { - const logs = await retrieveNetworkFailures(args.sessionId); - results.push({ - type: "text", - text: - logs.length > 0 - ? `Network Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` - : "No network failures found", - }); + response = await retrieveNetworkFailures(args.sessionId); + results.push({ type: "text", text: response }); break; } case AutomateLogType.SessionLogs: { - const logs = await retrieveSessionFailures(args.sessionId); - results.push({ - type: "text", - text: - logs.length > 0 - ? `Session Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` - : "No session failures found", - }); + response = await retrieveSessionFailures(args.sessionId); + results.push({ type: "text", text: response }); break; } case AutomateLogType.ConsoleLogs: { - const logs = await retrieveConsoleFailures(args.sessionId); - results.push({ - type: "text", - text: - logs.length > 0 - ? `Console Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` - : "No console failures found", - }); + response = await retrieveConsoleFailures(args.sessionId); + results.push({ type: "text", text: response }); break; } case AppAutomateLogType.DeviceLogs: { - const logs = await retrieveDeviceLogs(args.sessionId, args.buildId!); - results.push({ - type: "text", - text: - logs.length > 0 - ? `Device Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` - : "No device failures found", - }); + response = await retrieveDeviceLogs(args.sessionId, args.buildId!); + results.push({ type: "text", text: response }); break; } case AppAutomateLogType.AppiumLogs: { - const logs = await retrieveAppiumLogs(args.sessionId, args.buildId!); - results.push({ - type: "text", - text: - logs.length > 0 - ? `Appium Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` - : "No Appium failures found", - }); + response = await retrieveAppiumLogs(args.sessionId, args.buildId!); + results.push({ type: "text", text: response }); break; } case AppAutomateLogType.CrashLogs: { - const logs = await retrieveCrashLogs(args.sessionId, args.buildId!); - results.push({ - type: "text", - text: - logs.length > 0 - ? `Crash Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` - : "No crash failures found", - }); + response = await retrieveCrashLogs(args.sessionId, args.buildId!); + results.push({ type: "text", text: response }); break; } } diff --git a/tests/tools/getFailureLogs.test.ts b/tests/tools/getFailureLogs.test.ts index b6757ef3..9a2763c6 100644 --- a/tests/tools/getFailureLogs.test.ts +++ b/tests/tools/getFailureLogs.test.ts @@ -111,38 +111,32 @@ describe('BrowserStack Failure Logs', () => { }); describe('Automate Session Logs', () => { - const mockNetworkFailures = { - failures: [ + const mockNetworkFailures = + 'Network Failures (1 found):\n' + + JSON.stringify([ { startedDateTime: '2024-03-20T10:00:00Z', request: { method: 'GET', url: 'https://test.com' }, - response: { status: 404, statusText: 'Not Found' } - } - ], - totalFailures: 1 - }; + response: { status: 404, statusText: 'Not Found' }, + serverIPAddress: undefined, + time: undefined, + }, + ], null, 2); beforeEach(() => { // Reset all mocks vi.clearAllMocks(); - - // Setup mock implementations with resolved values + // Setup mock implementations with string return values vi.mocked(automate.retrieveNetworkFailures).mockResolvedValue(mockNetworkFailures); - vi.mocked(automate.retrieveSessionFailures).mockResolvedValue(['[ERROR] Test failed']); - vi.mocked(automate.retrieveConsoleFailures).mockResolvedValue(['Uncaught TypeError']); + vi.mocked(automate.retrieveSessionFailures).mockResolvedValue( + 'Session Failures (1 found):\n' + JSON.stringify(['[ERROR] Test failed'], null, 2) + ); + vi.mocked(automate.retrieveConsoleFailures).mockResolvedValue( + 'Console Failures (1 found):\n' + JSON.stringify(['Uncaught TypeError'], null, 2) + ); }); it('should fetch network logs successfully', async () => { - // Mock successful response with failures - const mockFailures = [ - { - startedDateTime: '2024-03-20T10:00:00Z', - request: { method: 'GET', url: 'https://test.com' }, - response: { status: 404, statusText: 'Not Found' } - } - ]; - vi.mocked(automate.retrieveNetworkFailures).mockResolvedValue(mockFailures); - const result = await getFailureLogs({ sessionId: mockSessionId, logTypes: ['networkLogs'], @@ -180,14 +174,16 @@ describe('BrowserStack Failure Logs', () => { }); describe('App-Automate Session Logs', () => { - const mockDeviceLogs = ['Fatal Exception: NullPointerException']; - const mockAppiumLogs = ['Error: Element not found']; - const mockCrashLogs = ['Application crashed due to signal 11']; - beforeEach(() => { - vi.mocked(appAutomate.retrieveDeviceLogs).mockResolvedValue(mockDeviceLogs); - vi.mocked(appAutomate.retrieveAppiumLogs).mockResolvedValue(mockAppiumLogs); - vi.mocked(appAutomate.retrieveCrashLogs).mockResolvedValue(mockCrashLogs); + vi.mocked(appAutomate.retrieveDeviceLogs).mockResolvedValue( + 'Device Failures (1 found):\n' + JSON.stringify(['Fatal Exception: NullPointerException'], null, 2) + ); + vi.mocked(appAutomate.retrieveAppiumLogs).mockResolvedValue( + 'Appium Failures (1 found):\n' + JSON.stringify(['Error: Element not found'], null, 2) + ); + vi.mocked(appAutomate.retrieveCrashLogs).mockResolvedValue( + 'Crash Failures (1 found):\n' + JSON.stringify(['Application crashed due to signal 11'], null, 2) + ); }); it('should fetch device logs successfully', async () => { @@ -232,7 +228,7 @@ describe('BrowserStack Failure Logs', () => { describe('Error Handling', () => { it('should handle empty log responses', async () => { - vi.mocked(automate.retrieveNetworkFailures).mockResolvedValue([]); + vi.mocked(automate.retrieveNetworkFailures).mockResolvedValue('No network failures found'); const result = await getFailureLogs({ sessionId: mockSessionId,