diff --git a/package-lock.json b/package-lock.json index 3f3ee8c..2e1adb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,11 @@ "version": "1.0.14", "license": "ISC", "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.1", + "@modelcontextprotocol/sdk": "^1.11.4", "@types/form-data": "^2.5.2", "axios": "^1.8.4", "browserstack-local": "^1.5.6", + "csv-parse": "^5.6.0", "dotenv": "^16.5.0", "form-data": "^4.0.2", "pino": "^9.6.0", @@ -27,6 +28,7 @@ }, "devDependencies": { "@eslint/js": "^9.25.0", + "@types/csv-parse": "^1.1.12", "@types/node": "^22.14.1", "@types/uuid": "^10.0.0", "eslint": "^9.25.0", @@ -1120,13 +1122,15 @@ "dev": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.1.tgz", - "integrity": "sha512-xNYdFdkJqEfIaTVP1gPKoEvluACHZsHZegIoICX8DM1o6Qf3G5u2BQJHmgd0n4YgRPqqK/u1ujQvrgAxxSJT9w==", + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.4.tgz", + "integrity": "sha512-OTbhe5slIjiOtLxXhKalkKGhIQrwvhgCDs/C2r8kcBTy5HR/g43aDQU0l7r8O0VGbJPTNJvDc7ZdQMdQDJXmbw==", + "license": "MIT", "dependencies": { + "ajv": "^8.17.1", "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", @@ -1151,6 +1155,22 @@ "node": ">= 0.6" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -1265,6 +1285,12 @@ "node": ">=0.10.0" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -1724,6 +1750,16 @@ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@types/csv-parse": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@types/csv-parse/-/csv-parse-1.1.12.tgz", + "integrity": "sha512-p6uZznjJOcFaymduLYf45ik28IYzChnkt+ofJOWa16bb2JRCHdxs/ai03q6raizCc5JuunVsbgtlDxfu9y2Nag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -3148,6 +3184,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/csv-parse": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", + "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", + "license": "MIT" + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -3990,8 +4032,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-fifo": { "version": "1.3.2", @@ -4052,6 +4093,22 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-parser": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", @@ -6115,6 +6172,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/package.json b/package.json index 3ee5758..f1d4ac6 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,11 @@ "author": "", "license": "ISC", "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.1", + "@modelcontextprotocol/sdk": "^1.11.4", "@types/form-data": "^2.5.2", "axios": "^1.8.4", "browserstack-local": "^1.5.6", + "csv-parse": "^5.6.0", "dotenv": "^16.5.0", "form-data": "^4.0.2", "pino": "^9.6.0", @@ -49,6 +50,7 @@ }, "devDependencies": { "@eslint/js": "^9.25.0", + "@types/csv-parse": "^1.1.12", "@types/node": "^22.14.1", "@types/uuid": "^10.0.0", "eslint": "^9.25.0", diff --git a/src/tools/accessibility.ts b/src/tools/accessibility.ts index 29ab6a1..4129086 100644 --- a/src/tools/accessibility.ts +++ b/src/tools/accessibility.ts @@ -1,34 +1,64 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { - startAccessibilityScan, - AccessibilityScanResponse, -} from "./accessiblity-utils/accessibility.js"; +import { AccessibilityScanner } from "./accessiblity-utils/scanner.js"; +import { AccessibilityReportFetcher } from "./accessiblity-utils/report-fetcher.js"; import { trackMCP } from "../lib/instrumentation.js"; +import { parseAccessibilityReportFromCSV } from "./accessiblity-utils/report-parser.js"; + +const scanner = new AccessibilityScanner(); +const reportFetcher = new AccessibilityReportFetcher(); async function runAccessibilityScan( name: string, pageURL: string, + context: any, ): Promise { - const response: AccessibilityScanResponse = await startAccessibilityScan( - name, - [pageURL], - ); - const scanId = response.data?.id; - const scanRunId = response.data?.scanRunId; + // Start scan + const startResp = await scanner.startScan(name, [pageURL]); + const scanId = startResp.data!.id; + const scanRunId = startResp.data!.scanRunId; - if (!scanId || !scanRunId) { - throw new Error( - "Unable to start a accessibility scan, please try again later or open an issue on GitHub if the problem persists", - ); + // Notify scan start + await context.sendNotification({ + method: "notifications/progress", + params: { + progressToken: context._meta?.progressToken ?? "NOT_FOUND", + message: `Accessibility scan "${name}" started`, + progress: 0, + total: 100, + }, + }); + + // Wait until scan completes + const status = await scanner.waitUntilComplete(scanId, scanRunId, context); + if (status !== "completed") { + return { + content: [ + { + type: "text", + text: `❌ Accessibility scan "${name}" failed with status: ${status} , check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`, + isError: true, + }, + ], + isError: true, + }; } + // Fetch CSV report link + const reportLink = await reportFetcher.getReportLink(scanId, scanRunId); + + const { records } = await parseAccessibilityReportFromCSV(reportLink); + return { content: [ { type: "text", - text: `Successfully queued accessibility scan, you will get a report via email within 5 minutes.`, + text: `✅ Accessibility scan "${name}" completed. check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`, + }, + { + type: "text", + text: `Scan results: ${JSON.stringify(records, null, 2)}`, }, ], }; @@ -37,15 +67,15 @@ async function runAccessibilityScan( export default function addAccessibilityTools(server: McpServer) { server.tool( "startAccessibilityScan", - "Use this tool to start an accessibility scan for a list of URLs on BrowserStack.", + "Start an accessibility scan via BrowserStack and retrieve a local CSV report path.", { name: z.string().describe("Name of the accessibility scan"), pageURL: z.string().describe("The URL to scan for accessibility issues"), }, - async (args) => { + async (args, context) => { try { trackMCP("startAccessibilityScan", server.server.getClientVersion()!); - return await runAccessibilityScan(args.name, args.pageURL); + return await runAccessibilityScan(args.name, args.pageURL, context); } catch (error) { trackMCP( "startAccessibilityScan", @@ -56,7 +86,9 @@ export default function addAccessibilityTools(server: McpServer) { content: [ { type: "text", - text: `Failed to start accessibility scan: ${error instanceof Error ? error.message : "Unknown error"}. Please open an issue on GitHub if the problem persists`, + text: `Failed to start accessibility scan: ${ + error instanceof Error ? error.message : "Unknown error" + }. Please open an issue on GitHub if the problem persists`, isError: true, }, ], diff --git a/src/tools/accessiblity-utils/accessibility.ts b/src/tools/accessiblity-utils/accessibility.ts deleted file mode 100644 index 5f9571d..0000000 --- a/src/tools/accessiblity-utils/accessibility.ts +++ /dev/null @@ -1,124 +0,0 @@ -import axios from "axios"; -import config from "../../config.js"; -import { AxiosError } from "axios"; - -export interface AccessibilityScanResponse { - success: boolean; - data?: { - id: string; - scanRunId: string; - }; - errors?: string[]; -} - -export async function startAccessibilityScan( - name: string, - urlList: string[], -): Promise { - try { - const response = await axios.post( - "https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", - { - name, - urlList, - recurring: false, - }, - { - auth: { - username: config.browserstackUsername, - password: config.browserstackAccessKey, - }, - }, - ); - - if (!response.data.success) { - throw new Error( - `Unable to create an accessibility scan: ${response.data.errors?.join(", ")}`, - ); - } - - return response.data; - } catch (error) { - if (error instanceof AxiosError) { - if (error.response?.data?.error) { - throw new Error( - `Failed to start accessibility scan: ${error.response?.data?.error}`, - ); - } else { - throw new Error( - `Failed to start accessibility scan: ${error.response?.data?.message || error.message}`, - ); - } - } - throw error; - } -} - -export interface AccessibilityScanStatus { - success: boolean; - data?: { - status: string; - }; - errors?: string[]; -} - -export async function pollScanStatus( - scanId: string, - scanRunId: string, -): Promise { - try { - const response = await axios.get( - `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/${scanRunId}/status`, - { - auth: { - username: config.browserstackUsername, - password: config.browserstackAccessKey, - }, - }, - ); - - if (!response.data.success) { - throw new Error( - `Failed to get scan status: ${response.data.errors?.join(", ")}`, - ); - } - - return response.data.data?.status || "unknown"; - } catch (error) { - if (error instanceof AxiosError) { - throw new Error( - `Failed to get scan status: ${error.response?.data?.message || error.message}`, - ); - } - throw error; - } -} - -export async function waitUntilScanComplete( - scanId: string, - scanRunId: string, -): Promise { - return new Promise((resolve, reject) => { - const interval = setInterval(async () => { - try { - const status = await pollScanStatus(scanId, scanRunId); - if (status === "completed") { - clearInterval(interval); - resolve(); - } - } catch (error) { - clearInterval(interval); - reject(error); - } - }, 5000); // Poll every 5 seconds - - // Set a timeout of 5 minutes - setTimeout( - () => { - clearInterval(interval); - reject(new Error("Scan timed out after 5 minutes")); - }, - 5 * 60 * 1000, - ); - }); -} diff --git a/src/tools/accessiblity-utils/report-fetcher.ts b/src/tools/accessiblity-utils/report-fetcher.ts new file mode 100644 index 0000000..498ea20 --- /dev/null +++ b/src/tools/accessiblity-utils/report-fetcher.ts @@ -0,0 +1,47 @@ +import axios from "axios"; +import config from "../../config.js"; + +interface ReportInitResponse { + success: true; + data: { task_id: string; message: string }; + error?: any; +} + +interface ReportResponse { + success: true; + data: { reportLink: string }; + error?: any; +} + +export class AccessibilityReportFetcher { + private auth = { + username: config.browserstackUsername, + password: config.browserstackAccessKey, + }; + + async getReportLink(scanId: string, scanRunId: string): Promise { + // Initiate CSV link generation + const initUrl = `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?scan_run_id=${scanRunId}`; + const initResp = await axios.get(initUrl, { + auth: this.auth, + }); + if (!initResp.data.success) { + throw new Error( + `Failed to initiate report: ${initResp.data.error || initResp.data.data.message}`, + ); + } + const taskId = initResp.data.data.task_id; + + // Fetch the generated CSV link + const reportUrl = `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?task_id=${encodeURIComponent( + taskId, + )}`; + const reportResp = await axios.get(reportUrl, { + auth: this.auth, + }); + if (!reportResp.data.success) { + throw new Error(`Failed to fetch report: ${reportResp.data.error}`); + } + return reportResp.data.data.reportLink; + } +} diff --git a/src/tools/accessiblity-utils/report-parser.ts b/src/tools/accessiblity-utils/report-parser.ts new file mode 100644 index 0000000..258c1f2 --- /dev/null +++ b/src/tools/accessiblity-utils/report-parser.ts @@ -0,0 +1,78 @@ +import fetch from "node-fetch"; +import { parse } from "csv-parse/sync"; + +type SimplifiedAccessibilityIssue = { + issue_type: string; + component: string; + issue_description: string; + HTML_snippet: string; + how_to_fix: string; + severity: string; +}; + +type PaginationOptions = { + /** How many JSON-chars max per “page” (default 10000) */ + maxCharacterLength?: number; + /** Character offset to start from (default 0) */ + nextPage?: number; +}; + +type PaginatedResult = { + records: SimplifiedAccessibilityIssue[]; + /** Character offset for the next page, or null if done */ + next_page: number | null; +}; + +export async function parseAccessibilityReportFromCSV( + reportLink: string, + { maxCharacterLength = 10_000, nextPage = 0 }: PaginationOptions = {}, +): Promise { + // 1) Download & parse + const res = await fetch(reportLink); + if (!res.ok) throw new Error(`Failed to download report: ${res.statusText}`); + const text = await res.text(); + const all: SimplifiedAccessibilityIssue[] = parse(text, { + columns: true, + skip_empty_lines: true, + }).map((row: any) => ({ + issue_type: row["Issue type"], + component: row["Component"], + issue_description: row["Issue description"], + HTML_snippet: row["HTML snippet"], + how_to_fix: row["How to fix this issue"], + severity: (row["Severity"] || "unknown").trim(), + })); + + // 2) Sort by severity + const order: Record = { + critical: 0, + serious: 1, + moderate: 2, + minor: 3, + }; + all.sort((a, b) => (order[a.severity] ?? 99) - (order[b.severity] ?? 99)); + + // 3) Walk to the starting offset + let charCursor = 0; + let idx = 0; + for (; idx < all.length; idx++) { + const len = JSON.stringify(all[idx]).length; + if (charCursor + len > nextPage) break; + charCursor += len; + } + + // 4) Collect up to maxCharacterLength + const page: SimplifiedAccessibilityIssue[] = []; + for (let i = idx; i < all.length; i++) { + const recStr = JSON.stringify(all[i]); + if (charCursor - nextPage + recStr.length > maxCharacterLength) break; + page.push(all[i]); + charCursor += recStr.length; + } + + const hasMore = idx + page.length < all.length; + return { + records: page, + next_page: hasMore ? charCursor : null, + }; +} diff --git a/src/tools/accessiblity-utils/scanner.ts b/src/tools/accessiblity-utils/scanner.ts new file mode 100644 index 0000000..8a02016 --- /dev/null +++ b/src/tools/accessiblity-utils/scanner.ts @@ -0,0 +1,116 @@ +import axios from "axios"; +import config from "../../config.js"; + +export interface AccessibilityScanResponse { + success: boolean; + data?: { id: string; scanRunId: string }; + errors?: string[]; +} + +export interface AccessibilityScanStatus { + success: boolean; + data?: { status: string }; + errors?: string[]; +} + +export class AccessibilityScanner { + private auth = { + username: config.browserstackUsername, + password: config.browserstackAccessKey, + }; + + async startScan( + name: string, + urlList: string[], + ): Promise { + try { + const { data } = await axios.post( + "https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", + { name, urlList, recurring: false }, + { auth: this.auth }, + ); + if (!data.success) + throw new Error(`Unable to start scan: ${data.errors?.join(", ")}`); + return data; + } catch (err) { + if (axios.isAxiosError(err) && err.response?.data) { + const msg = + (err.response.data as any).error || + (err.response.data as any).message || + err.message; + throw new Error(`Failed to start scan: ${msg}`); + } + throw err; + } + } + + async pollStatus( + scanId: string, + scanRunId: string, + ): Promise { + try { + const { data } = await axios.get( + `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/${scanRunId}/status`, + { auth: this.auth }, + ); + if (!data.success) + throw new Error(`Failed to get status: ${data.errors?.join(", ")}`); + return data; + } catch (err) { + if (axios.isAxiosError(err) && err.response?.data) { + const msg = (err.response.data as any).message || err.message; + throw new Error(`Failed to get scan status: ${msg}`); + } + throw err; + } + } + + async waitUntilComplete( + scanId: string, + scanRunId: string, + context: any, + ): Promise { + return new Promise((resolve, reject) => { + let timepercent = 0; + let dotCount = 1; + const interval = setInterval(async () => { + try { + const statusResp = await this.pollStatus(scanId, scanRunId); + const status = statusResp.data!.status; + timepercent += 1.67; + const progress = status === "completed" ? 100 : timepercent; + const dots = ".".repeat(dotCount); + dotCount = (dotCount % 4) + 1; + const message = + status === "completed" || status === "failed" + ? `Scan completed with status: ${status}` + : `Scan in progress${dots}`; + await context.sendNotification({ + method: "notifications/progress", + params: { + progressToken: context._meta?.progressToken ?? "NOT_FOUND", + message: message, + progress: progress, + total: 100, + }, + }); + if (status === "completed" || status === "failed") { + clearInterval(interval); + resolve(status); + } + } catch (e) { + clearInterval(interval); + reject(e); + } + }, 5000); + + setTimeout( + () => { + clearInterval(interval); + reject(new Error("Scan timed out after 5 minutes")); + }, + 5 * 60 * 1000, + ); + }); + } +} diff --git a/tests/tools/testmanagement.test.ts b/tests/tools/testmanagement.test.ts index 1195329..cdc0c62 100644 --- a/tests/tools/testmanagement.test.ts +++ b/tests/tools/testmanagement.test.ts @@ -151,7 +151,7 @@ describe('createTestCaseTool', () => { const result = await createTestCaseTool(validArgs); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to create test case: API Error'); + expect(result.content?.[0]?.text).toContain('Failed to create test case: API Error'); }); it('should handle unknown error while creating test case', async () => { @@ -160,7 +160,7 @@ describe('createTestCaseTool', () => { const result = await createTestCaseTool(validArgs); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Unknown error'); + expect(result.content?.[0]?.text).toContain('Unknown error'); }); }); @@ -194,7 +194,7 @@ describe('createProjectOrFolderTool', () => { const result = await createProjectOrFolderTool(validProjectArgs); expect(createProjectOrFolder).toHaveBeenCalledWith(validProjectArgs); - expect(result.content[0].text).toContain('Project created with identifier=proj-123'); + expect(result.content?.[0]?.text).toContain('Project created with identifier=proj-123'); }); it('should successfully create a folder', async () => { @@ -203,7 +203,7 @@ describe('createProjectOrFolderTool', () => { const result = await createProjectOrFolderTool(validFolderArgs); expect(createProjectOrFolder).toHaveBeenCalledWith(validFolderArgs); - expect(result.content[0].text).toContain('Folder created: ID=fold-123'); + expect(result.content?.[0]?.text).toContain('Folder created: ID=fold-123'); }); it('should handle error while creating project or folder', async () => { @@ -212,7 +212,7 @@ describe('createProjectOrFolderTool', () => { const result = await createProjectOrFolderTool(validProjectArgs); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( + expect(result.content?.[0]?.text).toContain( 'Failed to create project/folder: Failed to create project/folder. Please open an issue on GitHub if the problem persists' ); }); @@ -223,7 +223,7 @@ describe('createProjectOrFolderTool', () => { const result = await createProjectOrFolderTool(validProjectArgs); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( + expect(result.content?.[0]?.text).toContain( 'Failed to create project/folder: Unknown error. Please open an issue on GitHub if the problem persists' ); }); @@ -249,9 +249,9 @@ describe('listTestCases util', () => { expect.stringContaining('/projects/PR-1/test-cases?'), expect.objectContaining({ auth: expect.any(Object) }) ); - expect(result.content[0].text).toContain('Found 2 test case(s):'); - expect(result.content[0].text).toContain('TC-1: Test One [functional | high]'); - expect(result.content[1].text).toBe(JSON.stringify(mockCases, null, 2)); + expect(result.content?.[0]?.text).toContain('Found 2 test case(s):'); + expect(result.content?.[0]?.text).toContain('TC-1: Test One [functional | high]'); + expect(result.content?.[1]?.text).toBe(JSON.stringify(mockCases, null, 2)); }); it('should handle API errors gracefully', async () => { @@ -260,7 +260,7 @@ describe('listTestCases util', () => { const result = await listTestCases({ project_identifier: 'PR-1' } as any); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to list test cases: Network Error'); + expect(result.content?.[0]?.text).toContain('Failed to list test cases: Network Error'); }); }); @@ -297,7 +297,7 @@ describe('createTestRunTool', () => { const result = await createTestRunTool(validRunArgs as any); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to create test run: API Error'); + expect(result.content?.[0]?.text).toContain('Failed to create test run: API Error'); }); it('should handle unknown error while creating test run', async () => { @@ -306,7 +306,7 @@ describe('createTestRunTool', () => { const result = await createTestRunTool(validRunArgs as any); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Unknown error'); + expect(result.content?.[0]?.text).toContain('Unknown error'); }); }); @@ -334,15 +334,15 @@ describe('listTestRunsTool', () => { const result = await listTestRunsTool({ project_identifier: projectId } as any); expect(listTestRuns).toHaveBeenCalledWith({ project_identifier: projectId }); expect(result.isError).toBe(false); - expect(result.content[0].text).toContain('Found 2 test run(s):'); - expect(result.content[1].text).toBe(JSON.stringify(mockRuns, null, 2)); + expect(result.content?.[0]?.text).toContain('Found 2 test run(s):'); + expect(result.content?.[1]?.text).toBe(JSON.stringify(mockRuns, null, 2)); }); it('should handle errors', async () => { (listTestRuns as Mock).mockRejectedValue(new Error('Network Error')); const result = await listTestRunsTool({ project_identifier: projectId } as any); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to list test runs: Network Error'); + expect(result.content?.[0]?.text).toContain('Failed to list test runs: Network Error'); }); }); @@ -370,15 +370,15 @@ describe('updateTestRunTool', () => { const result = await updateTestRunTool(args as any); expect(updateTestRun).toHaveBeenCalledWith(args); expect(result.isError).toBe(false); - expect(result.content[0].text).toContain(`Successfully updated test run ${args.test_run_id}`); - expect(result.content[1].text).toBe(JSON.stringify(updated, null, 2)); + expect(result.content?.[0]?.text).toContain(`Successfully updated test run ${args.test_run_id}`); + expect(result.content?.[1]?.text).toBe(JSON.stringify(updated, null, 2)); }); it('should handle errors', async () => { (updateTestRun as Mock).mockRejectedValue(new Error('API Error')); const result = await updateTestRunTool(args as any); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to update test run: API Error'); + expect(result.content?.[0]?.text).toContain('Failed to update test run: API Error'); }); }); @@ -422,7 +422,7 @@ describe('addTestResultTool', () => { const result = await addTestResultTool(validArgs as any); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to add test result: Network Error'); + expect(result.content?.[0]?.text).toContain('Failed to add test result: Network Error'); }); it('should handle unknown errors gracefully', async () => { @@ -431,7 +431,7 @@ describe('addTestResultTool', () => { const result = await addTestResultTool(validArgs as any); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Unknown error'); + expect(result.content?.[0]?.text).toContain('Unknown error'); }); }); @@ -450,7 +450,7 @@ describe("uploadProductRequirementFileTool", () => { (fs.existsSync as Mock).mockReturnValue(false); const res = await uploadProductRequirementFileTool({ project_identifier: testProjectId, file_path: testFilePath }); expect(res.isError).toBe(true); - expect(res.content[0].text).toContain("does not exist"); + expect(res.content?.[0]?.text).toContain("does not exist"); }); it("uploads file and returns metadata", async () => { @@ -474,7 +474,7 @@ describe("uploadProductRequirementFileTool", () => { mockedAxios.post.mockResolvedValue(mockUpload); const res = await uploadProductRequirementFileTool({ project_identifier: testProjectId, file_path: testFilePath }); expect(res.isError ?? false).toBe(false); - expect(res.content[1].text).toContain("documentID"); + expect(res.content?.[1]?.text).toContain("documentID"); }); }); @@ -489,7 +489,7 @@ describe("createTestCasesFromFileTool", () => { const args = { documentId: testDocumentId, folderId: testFolderId, projectReferenceId: testProjectId }; const res = await createTestCasesFromFileTool(args as any, mockContext); expect(res.isError).toBe(true); - expect(res.content[0].text).toContain("Re-Upload the file"); + expect(res.content?.[0]?.text).toContain("Re-Upload the file"); }); it("creates test cases from a file successfully", async () => { @@ -524,6 +524,6 @@ describe("createTestCasesFromFileTool", () => { const res = await createTestCasesFromFileTool(args as any, mockContext); expect(res.isError ?? false).toBe(false); - expect(res.content[0].text).toContain("test cases created"); + expect(res.content?.[0]?.text).toContain("test cases created"); }); });